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
+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)