feat(auth): add interactive key policy editing
- Add auth policy to update existing authorized_keys allow policies locally or over remote serve. - Support key lookup by public key or exact name, with safe, all, server-default, and category-based modes. - Add questionary-powered interactive key selection and checkbox policy editing with current policy preselected. - Show policy descriptions in auth keys output so each capability is easier to understand. - Gate the new remote control command behind the existing keys policy category and include protocol routing/compat updates. - Bump real-browser-cli to 0.16.2 and lock the new questionary dependency. - Cover local, remote, validation, and policy-category behavior in tests.
This commit is contained in:
@@ -24,6 +24,7 @@ from browser_cli.auth.keys import (
|
|||||||
load_authorized_keys_with_policies,
|
load_authorized_keys_with_policies,
|
||||||
load_private_key,
|
load_private_key,
|
||||||
public_key_hex,
|
public_key_hex,
|
||||||
|
set_authorized_key_policy,
|
||||||
)
|
)
|
||||||
from browser_cli.auth.pq import (
|
from browser_cli.auth.pq import (
|
||||||
new_nonce,
|
new_nonce,
|
||||||
@@ -66,6 +67,7 @@ __all__ = [
|
|||||||
"pq_kex_server_decapsulate",
|
"pq_kex_server_decapsulate",
|
||||||
"pq_kex_server_keypair",
|
"pq_kex_server_keypair",
|
||||||
"public_key_hex",
|
"public_key_hex",
|
||||||
|
"set_authorized_key_policy",
|
||||||
"sign",
|
"sign",
|
||||||
"verify",
|
"verify",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -89,3 +89,37 @@ def add_authorized_key(path: Path, pub_hex: str, name: str = "", categories: lis
|
|||||||
with open(path, "a", encoding="utf-8") as file:
|
with open(path, "a", encoding="utf-8") as file:
|
||||||
file.write(line)
|
file.write(line)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def set_authorized_key_policy(path: Path, identifier: str, categories: list[str] | None) -> tuple[str, str] | None:
|
||||||
|
"""Update the per-key policy for a trusted key.
|
||||||
|
|
||||||
|
``identifier`` may be the full public key or an exact key name. ``categories``
|
||||||
|
is written as the ``allow:`` token; ``None`` removes the token so the key uses
|
||||||
|
the server default. Returns ``(pubkey, name)`` for the updated key, ``None`` if
|
||||||
|
no key matched, and raises ``ValueError`` for ambiguous names.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
wanted = identifier.strip()
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||||
|
matches: list[tuple[int, str, str, str]] = []
|
||||||
|
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
parsed = _parse_authorized_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
pubkey, name, _cats = parsed
|
||||||
|
if pubkey.lower() == wanted.lower() or (name and name == wanted):
|
||||||
|
newline = "\n" if line.endswith("\n") else ""
|
||||||
|
matches.append((index, pubkey, name, newline))
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise ValueError(f"ambiguous key name: {identifier!r} matches {len(matches)} keys")
|
||||||
|
|
||||||
|
index, pubkey, name, newline = matches[0]
|
||||||
|
lines[index] = format_authorized_line(pubkey, name, categories) + newline
|
||||||
|
path.write_text("".join(lines), encoding="utf-8")
|
||||||
|
return pubkey, name
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ DANGEROUS_PREFIXES = (
|
|||||||
KEY_COMMANDS = {
|
KEY_COMMANDS = {
|
||||||
"browser-cli.auth.keys",
|
"browser-cli.auth.keys",
|
||||||
"browser-cli.auth.trust",
|
"browser-cli.auth.trust",
|
||||||
|
"browser-cli.auth.policy",
|
||||||
}
|
}
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -98,6 +98,88 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control,
|
|||||||
else:
|
else:
|
||||||
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
|
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
|
||||||
|
|
||||||
|
@auth_group.command("policy")
|
||||||
|
@click.argument("identifier", required=False)
|
||||||
|
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||||
|
@click.option("--server-default", is_flag=True, help="Remove the per-key allow: token so this key uses the server default policy.")
|
||||||
|
@click.option("--safe", "safe_only", is_flag=True, help="Set an explicit safe-only policy (writes allow: with no categories).")
|
||||||
|
@command_policy_options
|
||||||
|
@click.pass_context
|
||||||
|
@handle_errors
|
||||||
|
def cmd_auth_policy(ctx, identifier, keys_file, server_default, safe_only, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||||
|
"""Change a trusted key's per-key policy.
|
||||||
|
|
||||||
|
IDENTIFIER may be the full public key or an exact key name. Omit IDENTIFIER in
|
||||||
|
an interactive terminal to pick a key first, then edit the policy with real
|
||||||
|
checkbox prompts. Use --safe for an explicit safe-only override,
|
||||||
|
--server-default to remove the override, or one or more --allow-* flags for
|
||||||
|
scriptable/non-interactive usage.
|
||||||
|
"""
|
||||||
|
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, set_authorized_key_policy
|
||||||
|
|
||||||
|
explicit_allow = any([allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all])
|
||||||
|
modes = sum(1 for enabled in [server_default, safe_only, explicit_allow] if enabled)
|
||||||
|
if modes > 1:
|
||||||
|
console.print("[red]Choose exactly one policy mode:[/red] --server-default, --safe, or one/more --allow-* flags")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
is_interactive = click.get_text_stream("stdin").isatty()
|
||||||
|
current_categories = None
|
||||||
|
if not identifier:
|
||||||
|
if not is_interactive:
|
||||||
|
console.print("[red]Missing key identifier:[/red] pass a public key/name, or run interactively to pick one")
|
||||||
|
sys.exit(1)
|
||||||
|
entry = _prompt_key_entry(_load_policy_entries(ctx, keys_file))
|
||||||
|
identifier = entry.get("pubkey") or entry.get("name") or ""
|
||||||
|
current_categories = entry.get("allow")
|
||||||
|
elif modes == 0 and is_interactive:
|
||||||
|
entry = _find_policy_entry(ctx, keys_file, identifier)
|
||||||
|
current_categories = entry.get("allow") if entry else None
|
||||||
|
|
||||||
|
if server_default:
|
||||||
|
categories = None
|
||||||
|
elif safe_only:
|
||||||
|
categories = []
|
||||||
|
elif explicit_allow:
|
||||||
|
categories = command_categories_from_options(
|
||||||
|
allow_read_page=allow_read_page, allow_control=allow_control,
|
||||||
|
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not is_interactive:
|
||||||
|
console.print("[red]Choose a policy mode:[/red] --server-default, --safe, one/more --allow-* flags, or run interactively")
|
||||||
|
sys.exit(1)
|
||||||
|
categories = _prompt_policy_categories(identifier, current_categories)
|
||||||
|
|
||||||
|
remote = (ctx.obj or {}).get("remote")
|
||||||
|
if remote:
|
||||||
|
from browser_cli.client import send_command
|
||||||
|
result = send_command(
|
||||||
|
"browser-cli.auth.policy",
|
||||||
|
args={"identifier": identifier, "allow": categories},
|
||||||
|
remote=remote,
|
||||||
|
key=(ctx.obj or {}).get("key"),
|
||||||
|
)
|
||||||
|
name = (result or {}).get("name") or ""
|
||||||
|
pubkey = (result or {}).get("pubkey") or identifier
|
||||||
|
label = f" ({name})" if name else ""
|
||||||
|
console.print(f"[green]✓[/green] Updated policy on {remote}{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||||
|
try:
|
||||||
|
updated = set_authorized_key_policy(path, identifier, categories)
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]{exc}[/red]")
|
||||||
|
sys.exit(1)
|
||||||
|
if updated is None:
|
||||||
|
console.print(f"[red]Trusted key not found:[/red] {identifier}")
|
||||||
|
sys.exit(1)
|
||||||
|
pubkey, name = updated
|
||||||
|
label = f" ({name})" if name else ""
|
||||||
|
console.print(f"[green]✓[/green] Updated policy{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
|
||||||
|
console.print(f" File: {path}")
|
||||||
|
|
||||||
@auth_group.command("show")
|
@auth_group.command("show")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--key",
|
"--key",
|
||||||
@@ -170,11 +252,164 @@ def cmd_auth_keys(ctx, keys_file):
|
|||||||
table.add_column("Name")
|
table.add_column("Name")
|
||||||
table.add_column("Public Key")
|
table.add_column("Public Key")
|
||||||
table.add_column("Policy")
|
table.add_column("Policy")
|
||||||
|
table.add_column("Description")
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
name = entry.get("name") or "[dim]—[/dim]"
|
name = entry.get("name") or "[dim]—[/dim]"
|
||||||
table.add_row(name, entry.get("pubkey", ""), _policy_label(entry.get("allow")))
|
allow = entry.get("allow")
|
||||||
|
table.add_row(name, entry.get("pubkey", ""), _policy_label(allow), _policy_description(allow))
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
def _load_policy_entries(ctx, keys_file):
|
||||||
|
"""Load trusted-key entries for interactive selection."""
|
||||||
|
remote = (ctx.obj or {}).get("remote")
|
||||||
|
if remote:
|
||||||
|
from browser_cli.client import send_command
|
||||||
|
return send_command(
|
||||||
|
"browser-cli.auth.keys",
|
||||||
|
remote=remote,
|
||||||
|
key=(ctx.obj or {}).get("key"),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
|
||||||
|
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||||
|
return [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
|
||||||
|
|
||||||
|
def _find_policy_entry(ctx, keys_file, identifier: str):
|
||||||
|
"""Find the current key entry so the checkbox prompt can preselect values."""
|
||||||
|
wanted = identifier.strip()
|
||||||
|
for entry in _load_policy_entries(ctx, keys_file):
|
||||||
|
pubkey = str(entry.get("pubkey") or "")
|
||||||
|
name = str(entry.get("name") or "")
|
||||||
|
if pubkey.lower() == wanted.lower() or (name and name == wanted):
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _prompt_key_entry(entries):
|
||||||
|
"""Interactive checkbox flow step 1: choose which key to edit."""
|
||||||
|
if not entries:
|
||||||
|
raise click.ClickException("no trusted keys found")
|
||||||
|
|
||||||
|
import questionary
|
||||||
|
|
||||||
|
choices = []
|
||||||
|
for entry in entries:
|
||||||
|
name = entry.get("name") or "unnamed key"
|
||||||
|
pubkey = entry.get("pubkey") or ""
|
||||||
|
policy = _plain_policy_label(entry.get("allow"))
|
||||||
|
choices.append(questionary.Choice(
|
||||||
|
title=f"{name} [{policy}] {pubkey[:12]}…{pubkey[-8:]}",
|
||||||
|
value=entry,
|
||||||
|
))
|
||||||
|
selected = questionary.select("Which trusted key do you want to edit?", choices=choices).ask()
|
||||||
|
if selected is None:
|
||||||
|
raise click.ClickException("cancelled")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def _prompt_policy_categories(identifier: str, current_categories=None):
|
||||||
|
"""Interactive policy picker for ``auth policy`` using real checkboxes."""
|
||||||
|
import questionary
|
||||||
|
|
||||||
|
checked = set(current_categories or [])
|
||||||
|
special_checked = {
|
||||||
|
"__server_default__": current_categories is None,
|
||||||
|
"__safe__": current_categories == [],
|
||||||
|
"__all__": isinstance(current_categories, list) and "all" in current_categories,
|
||||||
|
}
|
||||||
|
choices = [
|
||||||
|
questionary.Choice(
|
||||||
|
title="read-page — read page content: extract text/html/links/images, dom.text/query/exists",
|
||||||
|
value="read-page",
|
||||||
|
checked="read-page" in checked,
|
||||||
|
),
|
||||||
|
questionary.Choice(
|
||||||
|
title="control — control browser: open URLs, close tabs, click/type/scroll, sessions/groups",
|
||||||
|
value="control",
|
||||||
|
checked="control" in checked,
|
||||||
|
),
|
||||||
|
questionary.Choice(
|
||||||
|
title="dangerous — high risk: dom.eval JavaScript, storage access, screenshots",
|
||||||
|
value="dangerous",
|
||||||
|
checked="dangerous" in checked,
|
||||||
|
),
|
||||||
|
questionary.Choice(
|
||||||
|
title="keys — admin access to key management over --remote: auth keys/trust/policy",
|
||||||
|
value="keys",
|
||||||
|
checked="keys" in checked,
|
||||||
|
),
|
||||||
|
questionary.Separator(),
|
||||||
|
questionary.Choice(
|
||||||
|
title="all — allow everything",
|
||||||
|
value="__all__",
|
||||||
|
checked=special_checked["__all__"],
|
||||||
|
),
|
||||||
|
questionary.Choice(
|
||||||
|
title="safe — explicit safe-only override",
|
||||||
|
value="__safe__",
|
||||||
|
checked=special_checked["__safe__"],
|
||||||
|
),
|
||||||
|
questionary.Choice(
|
||||||
|
title="server default — remove per-key override and inherit server policy",
|
||||||
|
value="__server_default__",
|
||||||
|
checked=special_checked["__server_default__"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
selected = questionary.checkbox(
|
||||||
|
f"Policy for {identifier}",
|
||||||
|
choices=choices,
|
||||||
|
instruction="(space to toggle, enter to save)",
|
||||||
|
).ask()
|
||||||
|
if selected is None:
|
||||||
|
raise click.ClickException("cancelled")
|
||||||
|
return _parse_checkbox_policy_selection(selected)
|
||||||
|
|
||||||
|
def _parse_checkbox_policy_selection(selected):
|
||||||
|
special = [value for value in selected if value in {"__all__", "__safe__", "__server_default__"}]
|
||||||
|
normal = [value for value in selected if value not in {"__all__", "__safe__", "__server_default__"}]
|
||||||
|
if len(special) > 1 or (special and normal):
|
||||||
|
raise click.ClickException("select either categories, all, safe, or server default — not a mix")
|
||||||
|
if special == ["__server_default__"]:
|
||||||
|
return None
|
||||||
|
if special == ["__safe__"]:
|
||||||
|
return []
|
||||||
|
if special == ["__all__"]:
|
||||||
|
return ["all"]
|
||||||
|
return normal
|
||||||
|
|
||||||
|
def _parse_policy_selection(raw: str):
|
||||||
|
value = raw.strip().lower()
|
||||||
|
if value in {"default", "server-default", "server default", "inherit", "none"}:
|
||||||
|
return None
|
||||||
|
if value in {"safe", "safe-only", ""}:
|
||||||
|
return []
|
||||||
|
if value == "all":
|
||||||
|
return ["all"]
|
||||||
|
|
||||||
|
number_map = {
|
||||||
|
"1": "read-page",
|
||||||
|
"2": "control",
|
||||||
|
"3": "dangerous",
|
||||||
|
"4": "keys",
|
||||||
|
}
|
||||||
|
valid = {"read-page", "control", "dangerous", "keys"}
|
||||||
|
categories = []
|
||||||
|
for token in [part.strip() for part in value.replace(" ", ",").split(",") if part.strip()]:
|
||||||
|
category = number_map.get(token, token)
|
||||||
|
if category == "all":
|
||||||
|
return ["all"]
|
||||||
|
if category not in valid:
|
||||||
|
raise click.ClickException(f"unknown policy choice: {token}")
|
||||||
|
if category not in categories:
|
||||||
|
categories.append(category)
|
||||||
|
return categories
|
||||||
|
|
||||||
|
def _plain_policy_label(categories) -> str:
|
||||||
|
"""Plain-text policy label for interactive prompt titles."""
|
||||||
|
if categories is None:
|
||||||
|
return "server default"
|
||||||
|
if "all" in categories:
|
||||||
|
return "all"
|
||||||
|
return ", ".join(categories) if categories else "safe"
|
||||||
|
|
||||||
def _policy_label(categories) -> str:
|
def _policy_label(categories) -> str:
|
||||||
"""Render an authorized_keys ``allow:`` token for display."""
|
"""Render an authorized_keys ``allow:`` token for display."""
|
||||||
if categories is None:
|
if categories is None:
|
||||||
@@ -182,3 +417,20 @@ def _policy_label(categories) -> str:
|
|||||||
if "all" in categories:
|
if "all" in categories:
|
||||||
return "[yellow]all[/yellow]"
|
return "[yellow]all[/yellow]"
|
||||||
return ", ".join(categories) if categories else "safe"
|
return ", ".join(categories) if categories else "safe"
|
||||||
|
|
||||||
|
def _policy_description(categories) -> str:
|
||||||
|
"""Human-readable explanation for a policy category list."""
|
||||||
|
if categories is None:
|
||||||
|
return "Inherits the policy from browser-cli serve"
|
||||||
|
if "all" in categories:
|
||||||
|
return "Full access: page reads, browser control, dangerous commands, key admin"
|
||||||
|
if not categories:
|
||||||
|
return "Safe status/list commands only"
|
||||||
|
|
||||||
|
descriptions = {
|
||||||
|
"read-page": "read page content",
|
||||||
|
"control": "control browser/tabs/page input",
|
||||||
|
"dangerous": "run high-risk commands",
|
||||||
|
"keys": "manage trusted keys remotely",
|
||||||
|
}
|
||||||
|
return "; ".join(descriptions.get(category, category) for category in categories)
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ def _auth_0_9_3(msg: dict) -> dict:
|
|||||||
pk = msg.get("pubkey")
|
pk = msg.get("pubkey")
|
||||||
if isinstance(pk, str) and pk:
|
if isinstance(pk, str) and pk:
|
||||||
changed["pubkey"] = pk.lower()
|
changed["pubkey"] = pk.lower()
|
||||||
if msg.get("command") == "browser-cli.auth.trust":
|
if msg.get("command") in {"browser-cli.auth.trust", "browser-cli.auth.policy"}:
|
||||||
args = msg.get("args") or {}
|
args = msg.get("args") or {}
|
||||||
trust_pk = args.get("pubkey")
|
trust_pk = args.get("pubkey")
|
||||||
|
identifier = args.get("identifier")
|
||||||
|
patched = dict(args)
|
||||||
if isinstance(trust_pk, str) and trust_pk:
|
if isinstance(trust_pk, str) and trust_pk:
|
||||||
changed["args"] = {**args, "pubkey": trust_pk.lower()}
|
patched["pubkey"] = trust_pk.lower()
|
||||||
|
if isinstance(identifier, str) and identifier and len(identifier) == 64:
|
||||||
|
patched["identifier"] = identifier.lower()
|
||||||
|
if patched != args:
|
||||||
|
changed["args"] = patched
|
||||||
return {**msg, **changed} if changed else msg
|
return {**msg, **changed} if changed else msg
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ DEFAULT_TRANSPORT_THRESHOLD = 512
|
|||||||
# authenticated connection for multiple commands instead of re-handshaking.
|
# authenticated connection for multiple commands instead of re-handshaking.
|
||||||
REMOTE_SESSION_IDLE_TIMEOUT = 30
|
REMOTE_SESSION_IDLE_TIMEOUT = 30
|
||||||
|
|
||||||
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
|
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"}
|
||||||
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
|
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
|
||||||
|
|
||||||
PAGEABLE_COMMANDS = {
|
PAGEABLE_COMMANDS = {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class ServeControlMixin:
|
|||||||
|
|
||||||
if self.command == "browser-cli.auth.trust":
|
if self.command == "browser-cli.auth.trust":
|
||||||
return await self._handle_trust(msg)
|
return await self._handle_trust(msg)
|
||||||
|
|
||||||
|
if self.command == "browser-cli.auth.policy":
|
||||||
|
return await self._handle_policy(msg)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _handle_trust(self, msg: dict) -> bool:
|
async def _handle_trust(self, msg: dict) -> bool:
|
||||||
@@ -62,7 +65,6 @@ class ServeControlMixin:
|
|||||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||||
return True
|
return True
|
||||||
from browser_cli.auth import add_authorized_key
|
from browser_cli.auth import add_authorized_key
|
||||||
from browser_cli.serve.security import policy_from_categories
|
|
||||||
args = msg.get("args") or {}
|
args = msg.get("args") or {}
|
||||||
pubkey = str(args.get("pubkey") or "")
|
pubkey = str(args.get("pubkey") or "")
|
||||||
name = str(args.get("name") or "")
|
name = str(args.get("name") or "")
|
||||||
@@ -71,18 +73,54 @@ class ServeControlMixin:
|
|||||||
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
|
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
|
||||||
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
|
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
|
||||||
return True
|
return True
|
||||||
if categories is not None:
|
if not await self._validate_categories(categories):
|
||||||
if not isinstance(categories, list):
|
|
||||||
await self.send_error("invalid allow: expected a list of category strings")
|
|
||||||
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
policy_from_categories(categories) # validate before persisting
|
|
||||||
except ValueError as exc:
|
|
||||||
await self.send_error(str(exc))
|
|
||||||
log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label)
|
|
||||||
return True
|
return True
|
||||||
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
|
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
|
||||||
await self.send_ok({"added": added}, self.command)
|
await self.send_ok({"added": added}, self.command)
|
||||||
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label)
|
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _handle_policy(self, msg: dict) -> bool:
|
||||||
|
if self.auth_keys_path is None:
|
||||||
|
await self.send_error("no authorized keys file configured on this server")
|
||||||
|
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||||
|
return True
|
||||||
|
from browser_cli.auth import set_authorized_key_policy
|
||||||
|
args = msg.get("args") or {}
|
||||||
|
identifier = str(args.get("identifier") or "")
|
||||||
|
categories = args.get("allow")
|
||||||
|
if not identifier.strip():
|
||||||
|
await self.send_error("missing key identifier")
|
||||||
|
log_request(self.addr, self.command, None, "ERROR", "missing identifier", identity=self.auth_label)
|
||||||
|
return True
|
||||||
|
if not await self._validate_categories(categories):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
updated = set_authorized_key_policy(self.auth_keys_path, identifier, categories)
|
||||||
|
except ValueError as exc:
|
||||||
|
await self.send_error(str(exc))
|
||||||
|
log_request(self.addr, self.command, None, "ERROR", "ambiguous key", identity=self.auth_label)
|
||||||
|
return True
|
||||||
|
if updated is None:
|
||||||
|
await self.send_error(f"trusted key not found: {identifier}")
|
||||||
|
log_request(self.addr, self.command, None, "ERROR", "key not found", identity=self.auth_label)
|
||||||
|
return True
|
||||||
|
pubkey, name = updated
|
||||||
|
await self.send_ok({"updated": True, "pubkey": pubkey, "name": name, "allow": categories}, self.command)
|
||||||
|
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _validate_categories(self, categories) -> bool:
|
||||||
|
if categories is not None and not isinstance(categories, list):
|
||||||
|
await self.send_error("invalid allow: expected a list of category strings")
|
||||||
|
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
|
||||||
|
return False
|
||||||
|
if categories is not None:
|
||||||
|
from browser_cli.serve.security import policy_from_categories
|
||||||
|
try:
|
||||||
|
policy_from_categories(categories) # validate before persisting
|
||||||
|
except ValueError as exc:
|
||||||
|
await self.send_error(str(exc))
|
||||||
|
log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "real-browser-cli"
|
name = "real-browser-cli"
|
||||||
version = "0.16.0"
|
version = "0.16.2"
|
||||||
description = "Control your real running browser from the terminal or Python SDK"
|
description = "Control your real running browser from the terminal or Python SDK"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
"cryptography>=48",
|
"cryptography>=48",
|
||||||
"rich>=13",
|
"rich>=13",
|
||||||
"msgpack>=1",
|
"msgpack>=1",
|
||||||
|
"questionary>=2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -237,9 +237,77 @@ def test_auth_keys_local_shows_policy_column(tmp_path):
|
|||||||
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
|
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Policy" in result.output
|
assert "Policy" in result.output
|
||||||
|
assert "Description" in result.output
|
||||||
assert "read-page" in result.output
|
assert "read-page" in result.output
|
||||||
assert "all" in result.output
|
assert "all" in result.output
|
||||||
assert "server default" in result.output
|
assert "server default" in result.output
|
||||||
|
assert "read page content" in result.output
|
||||||
|
assert "Full access" in result.output
|
||||||
|
|
||||||
|
def test_auth_policy_updates_existing_key_policy(tmp_path):
|
||||||
|
keys = tmp_path / "authorized_keys"
|
||||||
|
pub = "a" * 64
|
||||||
|
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n")
|
||||||
|
result = CliRunner().invoke(main, [
|
||||||
|
"auth", "policy", pub, "--file", str(keys), "--allow-read-page", "--allow-control",
|
||||||
|
])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert keys.read_text().strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
|
||||||
|
assert "Updated policy" in result.output
|
||||||
|
|
||||||
|
def test_auth_policy_can_set_safe_and_server_default_by_name(tmp_path):
|
||||||
|
keys = tmp_path / "authorized_keys"
|
||||||
|
pub = "b" * 64
|
||||||
|
keys.write_text(f"{pub} laptop allow:all\n")
|
||||||
|
|
||||||
|
safe_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe"])
|
||||||
|
assert safe_result.exit_code == 0
|
||||||
|
assert keys.read_text().strip() == f"{pub} laptop allow:"
|
||||||
|
|
||||||
|
default_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--server-default"])
|
||||||
|
assert default_result.exit_code == 0
|
||||||
|
assert keys.read_text().strip() == f"{pub} laptop"
|
||||||
|
|
||||||
|
def test_auth_policy_requires_policy_mode_when_not_interactive(tmp_path):
|
||||||
|
keys = tmp_path / "authorized_keys"
|
||||||
|
keys.write_text(f"{'a' * 64} laptop\n")
|
||||||
|
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Choose a policy mode" in result.output
|
||||||
|
|
||||||
|
def test_auth_policy_rejects_conflicting_policy_modes(tmp_path):
|
||||||
|
keys = tmp_path / "authorized_keys"
|
||||||
|
keys.write_text(f"{'a' * 64} laptop\n")
|
||||||
|
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe", "--allow-all"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Choose exactly one policy mode" in result.output
|
||||||
|
|
||||||
|
def test_parse_interactive_policy_selection():
|
||||||
|
from browser_cli.commands.auth import _parse_checkbox_policy_selection, _parse_policy_selection
|
||||||
|
|
||||||
|
assert _parse_policy_selection("1,2") == ["read-page", "control"]
|
||||||
|
assert _parse_policy_selection("read-page control") == ["read-page", "control"]
|
||||||
|
assert _parse_policy_selection("all") == ["all"]
|
||||||
|
assert _parse_policy_selection("safe") == []
|
||||||
|
assert _parse_policy_selection("default") is None
|
||||||
|
assert _parse_checkbox_policy_selection(["read-page", "control"]) == ["read-page", "control"]
|
||||||
|
assert _parse_checkbox_policy_selection(["__all__"]) == ["all"]
|
||||||
|
assert _parse_checkbox_policy_selection(["__safe__"]) == []
|
||||||
|
assert _parse_checkbox_policy_selection(["__server_default__"]) is None
|
||||||
|
|
||||||
|
def test_auth_policy_without_identifier_requires_interactive_picker():
|
||||||
|
result = CliRunner().invoke(main, ["auth", "policy", "--allow-all"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Missing key identifier" in result.output
|
||||||
|
|
||||||
|
def test_auth_policy_remote_sends_policy_command():
|
||||||
|
pub = "c" * 64
|
||||||
|
with patch("browser_cli.client.send_command", return_value={"pubkey": pub, "name": "remote key", "allow": ["all"]}) as send:
|
||||||
|
result = CliRunner().invoke(main, ["--remote", "browser-host.example:8765", "auth", "policy", pub, "--allow-all"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
send.assert_called_once()
|
||||||
|
assert send.call_args.kwargs["args"] == {"identifier": pub, "allow": ["all"]}
|
||||||
|
assert "Updated policy" in result.output
|
||||||
|
|
||||||
def test_auth_keys_remote_unreachable_clean_error():
|
def test_auth_keys_remote_unreachable_clean_error():
|
||||||
"""`auth keys --remote` on an unreachable host shows a clean error, not a traceback."""
|
"""`auth keys --remote` on an unreachable host shows a clean error, not a traceback."""
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ def test_key_commands_are_keys_category():
|
|||||||
from browser_cli.command_security import command_category
|
from browser_cli.command_security import command_category
|
||||||
assert command_category("browser-cli.auth.keys") == "keys"
|
assert command_category("browser-cli.auth.keys") == "keys"
|
||||||
assert command_category("browser-cli.auth.trust") == "keys"
|
assert command_category("browser-cli.auth.trust") == "keys"
|
||||||
|
assert command_category("browser-cli.auth.policy") == "keys"
|
||||||
assert command_category("browser-cli.targets") == "safe" # discovery stays open
|
assert command_category("browser-cli.targets") == "safe" # discovery stays open
|
||||||
|
|
||||||
def test_key_commands_blocked_without_allow_keys():
|
def test_key_commands_blocked_without_allow_keys():
|
||||||
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust"):
|
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"):
|
||||||
with pytest.raises(PermissionError):
|
with pytest.raises(PermissionError):
|
||||||
assert_command_allowed(cmd, CommandPolicy()) # safe-only default
|
assert_command_allowed(cmd, CommandPolicy()) # safe-only default
|
||||||
assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant
|
assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant
|
||||||
|
|||||||
@@ -413,6 +413,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prompt-toolkit"
|
||||||
|
version = "3.0.52"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "wcwidth" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "3.0"
|
version = "3.0"
|
||||||
@@ -463,14 +475,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "questionary"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "prompt-toolkit" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "real-browser-cli"
|
name = "real-browser-cli"
|
||||||
version = "0.16.0"
|
version = "0.16.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "msgpack" },
|
{ name = "msgpack" },
|
||||||
|
{ name = "questionary" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -491,6 +516,7 @@ requires-dist = [
|
|||||||
{ name = "click", specifier = ">=8" },
|
{ name = "click", specifier = ">=8" },
|
||||||
{ name = "cryptography", specifier = ">=48" },
|
{ name = "cryptography", specifier = ">=48" },
|
||||||
{ name = "msgpack", specifier = ">=1" },
|
{ name = "msgpack", specifier = ">=1" },
|
||||||
|
{ name = "questionary", specifier = ">=2" },
|
||||||
{ name = "rich", specifier = ">=13" },
|
{ name = "rich", specifier = ">=13" },
|
||||||
{ name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" },
|
{ name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" },
|
||||||
]
|
]
|
||||||
@@ -579,6 +605,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wcwidth"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zstandard"
|
name = "zstandard"
|
||||||
version = "0.25.0"
|
version = "0.25.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user