feat(auth): add interactive key policy editing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 36s

- 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:
2026-06-18 15:02:18 +02:00
parent 6fa931aa36
commit 7fe0e27fec
11 changed files with 454 additions and 16 deletions
+2
View File
@@ -24,6 +24,7 @@ from browser_cli.auth.keys import (
load_authorized_keys_with_policies,
load_private_key,
public_key_hex,
set_authorized_key_policy,
)
from browser_cli.auth.pq import (
new_nonce,
@@ -66,6 +67,7 @@ __all__ = [
"pq_kex_server_decapsulate",
"pq_kex_server_keypair",
"public_key_hex",
"set_authorized_key_policy",
"sign",
"verify",
]
+34
View File
@@ -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:
file.write(line)
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
+1
View File
@@ -80,6 +80,7 @@ DANGEROUS_PREFIXES = (
KEY_COMMANDS = {
"browser-cli.auth.keys",
"browser-cli.auth.trust",
"browser-cli.auth.policy",
}
@dataclass(frozen=True)
+253 -1
View File
@@ -98,6 +98,88 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control,
else:
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")
@click.option(
"--key",
@@ -170,11 +252,164 @@ def cmd_auth_keys(ctx, keys_file):
table.add_column("Name")
table.add_column("Public Key")
table.add_column("Policy")
table.add_column("Description")
for entry in entries:
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)
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:
"""Render an authorized_keys ``allow:`` token for display."""
if categories is None:
@@ -182,3 +417,20 @@ def _policy_label(categories) -> str:
if "all" in categories:
return "[yellow]all[/yellow]"
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)
+8 -2
View File
@@ -20,11 +20,17 @@ def _auth_0_9_3(msg: dict) -> dict:
pk = msg.get("pubkey")
if isinstance(pk, str) and pk:
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 {}
trust_pk = args.get("pubkey")
identifier = args.get("identifier")
patched = dict(args)
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
+1 -1
View File
@@ -44,7 +44,7 @@ DEFAULT_TRANSPORT_THRESHOLD = 512
# authenticated connection for multiple commands instead of re-handshaking.
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"]
PAGEABLE_COMMANDS = {
+47 -9
View File
@@ -54,6 +54,9 @@ class ServeControlMixin:
if self.command == "browser-cli.auth.trust":
return await self._handle_trust(msg)
if self.command == "browser-cli.auth.policy":
return await self._handle_policy(msg)
return False
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")
return True
from browser_cli.auth import add_authorized_key
from browser_cli.serve.security import policy_from_categories
args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "")
@@ -71,18 +73,54 @@ class ServeControlMixin:
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)
return True
if not await self._validate_categories(categories):
return True
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
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)
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:
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
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 True
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
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)
return False
return True