diff --git a/packages/agentveil-mcp-proxy/agentveil_mcp_proxy/cli.py b/packages/agentveil-mcp-proxy/agentveil_mcp_proxy/cli.py index 130f5ea..1c403e1 100644 --- a/packages/agentveil-mcp-proxy/agentveil_mcp_proxy/cli.py +++ b/packages/agentveil-mcp-proxy/agentveil_mcp_proxy/cli.py @@ -1394,6 +1394,7 @@ def doctor_proxy( ) for warning in warnings: print(f"WARN: {warning}", file=out) + _print_role_presets_discoverability_hint(out) return 0 except ProxyCliError as exc: if output_json: @@ -2798,6 +2799,16 @@ def _with_path_args(parts: list[str]) -> str: return doctor_cmd, client_cmd +_ROLE_PRESETS_DISCOVERABILITY_HINT = ( + "Role presets: available. Inspect with " + "`agentveil-mcp-proxy role doctor --preset reviewer`." +) + + +def _print_role_presets_discoverability_hint(out: TextIO) -> None: + print(_ROLE_PRESETS_DISCOVERABILITY_HINT, file=out) + + def _add_passphrase_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--passphrase", default=None, help="MCP proxy identity passphrase") parser.add_argument("--passphrase-file", type=Path, default=None, help="Read passphrase from file") @@ -3538,7 +3549,7 @@ def run_redirect_approval_demo_cli( "\n" "Advanced:\n" " approval-center, client-config, client-doctor, client-run, configure-downstream,\n" - " connect, control, disconnect, downstream, evidence-summary, explain, hook,\n" + " connect, control, disconnect, downstream, evidence-summary, explain, hook, role,\n" " install-claude-hook, permission-doctor, reissue-grant, register, smoke,\n" " status-claude-hook, templates, uninstall-claude-hook, wizard\n" "\n" @@ -4187,6 +4198,35 @@ def build_parser() -> argparse.ArgumentParser: ) _add_json_arg(explain_role) + role = subparsers.add_parser( + "role", + help="Inspect static role preset action-boundary behavior", + description=( + "Inspect what a role preset may read, block, or require approval for. " + "Roles map to action-boundary decisions; they are not inferred policy." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " agentveil-mcp-proxy role doctor --preset reviewer\n" + " agentveil-mcp-proxy explain role --preset reviewer" + ), + ) + role_subparsers = role.add_subparsers(dest="role_action", required=True) + role_doctor = role_subparsers.add_parser( + "doctor", + # claim-check: allow "blocked" is bounded role-doctor action-family status text. + help="Show allowed, blocked, and approval-required action families by role preset", + ) + _add_common_path_args(role_doctor) + role_doctor.add_argument( + "--preset", + choices=list(ROLE_PRESET_NAMES), + default=None, + help="Inspect one preset; default reads role_preset from config or the preset set", + ) + _add_json_arg(role_doctor) + templates = subparsers.add_parser( "templates", help="Print copy-paste runnable starter commands for review/build/readonly agents", @@ -6479,6 +6519,7 @@ def main(argv: list[str] | None = None) -> int: ) print(f"Next: {doctor_cmd}") print(f" {client_cmd}") + _print_role_presets_discoverability_hint(sys.stdout) return 0 if args.command == "doctor": return doctor_proxy( @@ -6784,6 +6825,15 @@ def main(argv: list[str] | None = None) -> int: preset=args.preset, output_json=args.json_output, ) + if args.command == "role": + if args.role_action != "doctor": + raise ProxyCliError("role action must be doctor") + return explain_role_proxy( + home=args.home, + config_path=args.config, + preset=args.preset, + output_json=args.json_output, + ) if args.command == "templates": if args.templates_action != "print": raise ProxyCliError("templates action must be print") diff --git a/packages/agentveil-mcp-proxy/agentveil_mcp_proxy/role_doctor.py b/packages/agentveil-mcp-proxy/agentveil_mcp_proxy/role_doctor.py index f7bf866..4c57b02 100644 --- a/packages/agentveil-mcp-proxy/agentveil_mcp_proxy/role_doctor.py +++ b/packages/agentveil-mcp-proxy/agentveil_mcp_proxy/role_doctor.py @@ -403,9 +403,10 @@ def build_role_preset_guide(preset_name: str) -> RolePresetGuide: approval_required_action_families=POLICY_APPROVAL_ACTION_FAMILIES, blocked_action_families=MUTATION_ACTION_FAMILIES, summary=( - f"{_preset_label(preset.name)} may read and inspect tools; " + f"{_preset_label(preset.name)} may read and inspect; " # claim-check: allow "blocked" is bounded role-doctor status text. - "mutation and command actions are blocked by role authority." + "file changes and shell commands are blocked at the action boundary. " + "Hand implementation work to an Implementer or Build agent." ), ) return RolePresetGuide( @@ -453,7 +454,11 @@ def format_role_doctor_report(report: Mapping[str, Any]) -> str: """Render human-readable role doctor output.""" if "presets" in report: - lines = ["Role doctor: preset capabilities", ""] + lines = [ + "Role doctor: static action-boundary map for role presets.", + "Roles provide context for allow, approval, and deny decisions.", + "", + ] for preset_report in report["presets"]: lines.append(format_role_doctor_report(preset_report)) lines.append("") @@ -471,6 +476,10 @@ def format_role_doctor_report(report: Mapping[str, Any]) -> str: # claim-check: allow "Blocked" is the literal bounded role-doctor label. f"Blocked action families: {', '.join(report['blocked_action_families']) or 'none'}", f"Summary: {report['summary']}", + ( + "Action boundary: role authority helps decide whether each tool call " + "is allowed, needs approval, or is blocked." + ), ] return "\n".join(lines) diff --git a/packages/agentveil-mcp-proxy/tests/test_mcp_proxy_cli.py b/packages/agentveil-mcp-proxy/tests/test_mcp_proxy_cli.py index 04bd1df..223d5f3 100644 --- a/packages/agentveil-mcp-proxy/tests/test_mcp_proxy_cli.py +++ b/packages/agentveil-mcp-proxy/tests/test_mcp_proxy_cli.py @@ -3109,3 +3109,106 @@ def capture_center_start(**kwargs): assert rc == 0 assert captured["proxy_command"] == sys.executable _assert_demo_managed_approval_center_stopped(tmp_path / "demo-source-route" / "avp-home") + + +_ROLE_DOCTOR_FORBIDDEN_WORDING = ( + "AgentVeil learned", + "inferred role", +) + + +def _assert_role_doctor_reviewer_output(output: str) -> None: + assert "Preset: reviewer" in output + assert "Allowed action families" in output + assert "Blocked action families" in output + assert "Action boundary:" in output + assert "Implementer" in output + assert "Build agent" in output + lowered = output.lower() + for forbidden in _ROLE_DOCTOR_FORBIDDEN_WORDING: + assert forbidden.lower() not in lowered + assert "adaptive recommendation" not in lowered + assert "before local policy" not in lowered + + +def test_role_doctor_reviewer_preset_shows_action_boundary(capsys): + assert main(["role", "doctor", "--preset", "reviewer"]) == 0 + _assert_role_doctor_reviewer_output(capsys.readouterr().out) + + +def test_explain_role_reviewer_still_works(capsys): + assert main(["explain", "role", "--preset", "reviewer"]) == 0 + _assert_role_doctor_reviewer_output(capsys.readouterr().out) + + +def test_role_doctor_and_explain_role_reviewer_match(capsys): + assert main(["role", "doctor", "--preset", "reviewer"]) == 0 + role_output = capsys.readouterr().out + assert main(["explain", "role", "--preset", "reviewer"]) == 0 + explain_output = capsys.readouterr().out + assert role_output == explain_output + + +def test_role_doctor_help_is_discoverable(capsys): + with pytest.raises(SystemExit) as exc: + main(["role", "doctor", "--help"]) + assert exc.value.code == 0 + text = capsys.readouterr().out + assert "--preset" in text + assert "reviewer" in text + + +def test_root_help_lists_role_doctor(capsys): + with pytest.raises(SystemExit) as exc: + main(["--help"]) + assert exc.value.code == 0 + text = capsys.readouterr().out + advanced = text.split("Advanced:", 1)[1].split("Typical first-run path:", 1)[0] + assert "role" in advanced + + +def test_doctor_human_output_includes_role_presets_hint(tmp_path): + home = tmp_path / "avp-home" + init_proxy(home=home, agent_name="proxy", passphrase=TEST_PASSPHRASE) + out = io.StringIO() + assert doctor_proxy(home=home, passphrase=TEST_PASSPHRASE, out=out) == 0 + text = out.getvalue() + assert proxy_cli._ROLE_PRESETS_DISCOVERABILITY_HINT in text + assert "inferred" not in text.lower() + assert "adaptive" not in text.lower() + + +def test_doctor_json_output_omits_role_presets_hint(tmp_path): + home = tmp_path / "avp-home" + init_proxy(home=home, agent_name="proxy", passphrase=TEST_PASSPHRASE) + out = io.StringIO() + assert doctor_proxy( + home=home, + passphrase=TEST_PASSPHRASE, + output_json=True, + out=out, + ) == 0 + payload = json.loads(out.getvalue()) + serialized = json.dumps(payload) + assert proxy_cli._ROLE_PRESETS_DISCOVERABILITY_HINT not in serialized + assert "role doctor" not in serialized + + +def test_init_human_output_includes_role_presets_hint(tmp_path, capsys): + home = tmp_path / "avp-home" + assert main(["init", "--home", str(home), "--plaintext", "--force"]) == 0 + text = capsys.readouterr().out + assert proxy_cli._ROLE_PRESETS_DISCOVERABILITY_HINT in text + assert "Next:" in text + + +def test_demo_human_output_omits_role_presets_hint(tmp_path, capsys): + work_dir = tmp_path / "demo-no-role-hint" + assert main([ + "demo", + "--work-dir", + str(work_dir), + "--demo-auto-approve", + ]) == 0 + text = capsys.readouterr().out + assert proxy_cli._ROLE_PRESETS_DISCOVERABILITY_HINT not in text