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_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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,7 @@ DANGEROUS_PREFIXES = (
|
||||
KEY_COMMANDS = {
|
||||
"browser-cli.auth.keys",
|
||||
"browser-cli.auth.trust",
|
||||
"browser-cli.auth.policy",
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "real-browser-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
description = "Control your real running browser from the terminal or Python SDK"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"cryptography>=48",
|
||||
"rich>=13",
|
||||
"msgpack>=1",
|
||||
"questionary>=2",
|
||||
]
|
||||
|
||||
[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)])
|
||||
assert result.exit_code == 0
|
||||
assert "Policy" in result.output
|
||||
assert "Description" in result.output
|
||||
assert "read-page" in result.output
|
||||
assert "all" 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():
|
||||
"""`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
|
||||
assert command_category("browser-cli.auth.keys") == "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
|
||||
|
||||
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):
|
||||
assert_command_allowed(cmd, CommandPolicy()) # safe-only default
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pycparser"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "real-browser-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "msgpack" },
|
||||
{ name = "questionary" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
@@ -491,6 +516,7 @@ requires-dist = [
|
||||
{ name = "click", specifier = ">=8" },
|
||||
{ name = "cryptography", specifier = ">=48" },
|
||||
{ name = "msgpack", specifier = ">=1" },
|
||||
{ name = "questionary", specifier = ">=2" },
|
||||
{ name = "rich", specifier = ">=13" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
Reference in New Issue
Block a user