From 7fe0e27fec2e6a3fbf4e4a2a1d2b325545837f69 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Thu, 18 Jun 2026 15:02:18 +0200 Subject: [PATCH] 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. --- browser_cli/auth/__init__.py | 2 + browser_cli/auth/keys.py | 34 ++++ browser_cli/command_security.py | 1 + browser_cli/commands/auth.py | 254 ++++++++++++++++++++++++++++- browser_cli/compat/auth.py | 10 +- browser_cli/constants.py | 2 +- browser_cli/serve/control.py | 56 ++++++- pyproject.toml | 3 +- tests/test_new_feature_commands.py | 68 ++++++++ tests/test_serve_security.py | 3 +- uv.lock | 37 ++++- 11 files changed, 454 insertions(+), 16 deletions(-) diff --git a/browser_cli/auth/__init__.py b/browser_cli/auth/__init__.py index 1306d4f..62956ba 100644 --- a/browser_cli/auth/__init__.py +++ b/browser_cli/auth/__init__.py @@ -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", ] diff --git a/browser_cli/auth/keys.py b/browser_cli/auth/keys.py index 416e4d6..e82c999 100644 --- a/browser_cli/auth/keys.py +++ b/browser_cli/auth/keys.py @@ -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 diff --git a/browser_cli/command_security.py b/browser_cli/command_security.py index 567e165..78b736b 100644 --- a/browser_cli/command_security.py +++ b/browser_cli/command_security.py @@ -80,6 +80,7 @@ DANGEROUS_PREFIXES = ( KEY_COMMANDS = { "browser-cli.auth.keys", "browser-cli.auth.trust", + "browser-cli.auth.policy", } @dataclass(frozen=True) diff --git a/browser_cli/commands/auth.py b/browser_cli/commands/auth.py index ada3932..146ea14 100644 --- a/browser_cli/commands/auth.py +++ b/browser_cli/commands/auth.py @@ -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) diff --git a/browser_cli/compat/auth.py b/browser_cli/compat/auth.py index c7a6eb0..0586a13 100644 --- a/browser_cli/compat/auth.py +++ b/browser_cli/compat/auth.py @@ -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 diff --git a/browser_cli/constants.py b/browser_cli/constants.py index a16f7f0..6e4a162 100644 --- a/browser_cli/constants.py +++ b/browser_cli/constants.py @@ -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 = { diff --git a/browser_cli/serve/control.py b/browser_cli/serve/control.py index 6b0145f..79dd3dd 100644 --- a/browser_cli/serve/control.py +++ b/browser_cli/serve/control.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index df0adca..3a3c245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/test_new_feature_commands.py b/tests/test_new_feature_commands.py index 0fe2686..438b6a2 100644 --- a/tests/test_new_feature_commands.py +++ b/tests/test_new_feature_commands.py @@ -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.""" diff --git a/tests/test_serve_security.py b/tests/test_serve_security.py index 886e132..52e4f2e 100644 --- a/tests/test_serve_security.py +++ b/tests/test_serve_security.py @@ -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 diff --git a/uv.lock b/uv.lock index 7edc8ec..66634d7 100644 --- a/uv.lock +++ b/uv.lock @@ -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"