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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user