Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion packages/agentveil-mcp-proxy/agentveil_mcp_proxy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 12 additions & 3 deletions packages/agentveil-mcp-proxy/agentveil_mcp_proxy/role_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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("")
Expand All @@ -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)

Expand Down
103 changes: 103 additions & 0 deletions packages/agentveil-mcp-proxy/tests/test_mcp_proxy_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading