From 8bc85ba19e8c7d47a5593ab20c2a949e28182df6 Mon Sep 17 00:00:00 2001 From: Huangshuo Kuang <141250392+kkkhs@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:21:23 +0000 Subject: [PATCH] feat(cli): add detection disposition command --- README.md | 8 +- apps/orchestrator/agentmetry/cli/__init__.py | 55 ++++++++ apps/orchestrator/tests/test_cli_commands.py | 132 +++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d2b98f0..359fae8 100644 --- a/README.md +++ b/README.md @@ -588,7 +588,7 @@ GET /api/v1/audit/detections/{correlation_id} ### Triage: what the human decided A detection nobody answered is an alert, not a control. Every finding carries a -disposition, set from the Detections tab or over the API: +disposition, set from the Detections tab, the CLI, or over the API: | Status | Meaning | | ------ | ------- | @@ -605,6 +605,11 @@ POST /api/v1/audit/detections/disposition "status": "risk_accepted", "note": "known internal test harness"} ``` +```bash +agentmetry disposition run-42 credential-exfil \ + --status risk_accepted --note "known internal test harness" +``` + Three properties make this evidence rather than a checkbox: 1. **The decision is an event.** Each change is appended to the trail as @@ -775,6 +780,7 @@ visibility into agents Agentmetry does not orchestrate. | `agentmetry backup` / `restore` | Zip the vault and data stores; restore one (server stopped) | | `agentmetry dogfood` / `--start` | Score the four-week beta gate, or start its clock | | `agentmetry stats --days 7` | Weekly audit metrics (events, sessions, detections, DLP/policy blocks) | +| `agentmetry disposition ` | Close a detection through the API with `--status resolved\|false_positive\|risk_accepted`; false positives and accepted risks require `--note` | | `agentmetry replay ` | ASCII audit timeline for one session (audit trail) | | `agentmetry export --evidence` | Tamper-evident batch pack (JSON + SHA-256) | | `agentmetry export --compliance-digest` | Period governance summary for control review (Markdown; `--json` available) | diff --git a/apps/orchestrator/agentmetry/cli/__init__.py b/apps/orchestrator/agentmetry/cli/__init__.py index e6f4cca..49b763a 100644 --- a/apps/orchestrator/agentmetry/cli/__init__.py +++ b/apps/orchestrator/agentmetry/cli/__init__.py @@ -34,6 +34,7 @@ logger = logging.getLogger(__name__) _BACKUP_EXCLUDE_SUFFIXES = {".pid"} +_CLOSING_DISPOSITIONS = ("resolved", "false_positive", "risk_accepted") def _base_url(port: int, host: str = "127.0.0.1") -> str: @@ -41,6 +42,15 @@ def _base_url(port: int, host: str = "127.0.0.1") -> str: return f"http://{display}:{port}" +def _api_base_url(port: int) -> str: + return os.environ.get("AGENTMETRY_URL", "").strip().rstrip("/") or _base_url(port) + + +def _api_headers() -> dict[str, str]: + key = os.environ.get("AGENTMETRY_API_KEY", "").strip() + return {"X-API-Key": key} if key else {} + + def _lan_ip() -> str | None: """Best-effort local IPv4 for phone/LAN access hints.""" try: @@ -274,6 +284,44 @@ def cmd_detections(args: argparse.Namespace) -> int: return 0 +def cmd_disposition(args: argparse.Namespace) -> int: + """Close a detection from the shell, using the same API as the dashboard.""" + note = args.note.strip() + if args.status in {"false_positive", "risk_accepted"} and not note: + print(f"--note is required for {args.status}") + return 1 + + try: + resp = httpx.post( + f"{_api_base_url(args.port)}/api/v1/audit/detections/disposition", + json={ + "correlation_id": args.correlation_id, + "rule_id": args.rule_id, + "status": args.status, + "note": note, + "decided_by": args.decided_by, + }, + headers=_api_headers(), + timeout=10.0, + ) + except Exception: + print("Not running - start Agentmetry first (disposition writes via the API).") + return 1 + + if resp.status_code >= 400: + try: + detail = resp.json().get("detail") + except Exception: + detail = None + print(f"FAILED — {detail or resp.text or f'HTTP {resp.status_code}'}") + return 1 + + current = resp.json().get("disposition", {}) + status = current.get("status", args.status) + print(f"Disposition set: {args.correlation_id} {args.rule_id} -> {status}") + return 0 + + def cmd_logs(args: argparse.Namespace) -> int: log = _DATA_DIR / "logs" / "orchestrator.log" if not log.exists(): @@ -1167,6 +1215,12 @@ def main(argv: list[str] | None = None) -> int: stats.add_argument("--days", type=int, default=7) detections = sub.add_parser("detections", help="list detections for one session") detections.add_argument("correlation_id", help="correlation_id / session id") + disposition = sub.add_parser("disposition", help="close an audit detection from the shell") + disposition.add_argument("correlation_id", help="session/correlation id that owns the detection") + disposition.add_argument("rule_id", help="detection rule id to close") + disposition.add_argument("--status", required=True, choices=_CLOSING_DISPOSITIONS) + disposition.add_argument("--note", default="") + disposition.add_argument("--decided-by", default="") logs = sub.add_parser("logs", help="tail the orchestrator log") logs.add_argument("-n", "--lines", type=int, default=50) logs.add_argument("-f", "--follow", action="store_true") @@ -1309,6 +1363,7 @@ def main(argv: list[str] | None = None) -> int: "status": cmd_status, "stats": cmd_stats, "detections": cmd_detections, + "disposition": cmd_disposition, "logs": cmd_logs, "backup": cmd_backup, "restore": cmd_restore, diff --git a/apps/orchestrator/tests/test_cli_commands.py b/apps/orchestrator/tests/test_cli_commands.py index 4f7d5b8..fa1135b 100644 --- a/apps/orchestrator/tests/test_cli_commands.py +++ b/apps/orchestrator/tests/test_cli_commands.py @@ -426,6 +426,138 @@ def boom(*_args, **_kwargs): assert "Not running" in capsys.readouterr().out +def test_disposition_posts_the_closing_decision(monkeypatch, capsys): + from agentmetry import cli + + calls = {} + + class Resp: + status_code = 200 + + @staticmethod + def json(): + return {"disposition": {"status": "resolved"}} + + def post(url, **kwargs): + calls["url"] = url + calls.update(kwargs) + return Resp() + + monkeypatch.delenv("AGENTMETRY_URL", raising=False) + monkeypatch.delenv("AGENTMETRY_API_KEY", raising=False) + monkeypatch.setattr(cli.httpx, "post", post) + + assert main([ + "disposition", + "sess-1", + "credential-exfil", + "--status", + "resolved", + ]) == 0 + + assert calls["url"] == "http://127.0.0.1:8000/api/v1/audit/detections/disposition" + assert calls["json"] == { + "correlation_id": "sess-1", + "rule_id": "credential-exfil", + "status": "resolved", + "note": "", + "decided_by": "", + } + assert calls["headers"] == {} + assert "sess-1 credential-exfil -> resolved" in capsys.readouterr().out + + +def test_disposition_uses_configured_url_and_api_key(monkeypatch): + from agentmetry import cli + + calls = {} + + class Resp: + status_code = 200 + + @staticmethod + def json(): + return {"disposition": {"status": "risk_accepted"}} + + monkeypatch.setenv("AGENTMETRY_URL", "http://agentmetry.test/") + monkeypatch.setenv("AGENTMETRY_API_KEY", "secret-token") + monkeypatch.setattr( + cli.httpx, + "post", + lambda url, **kwargs: calls.update({"url": url, **kwargs}) or Resp(), + ) + + assert main([ + "disposition", + "sess-1", + "session-tool-burst", + "--status", + "risk_accepted", + "--note", + "known load test", + "--decided-by", + "home-lab", + ]) == 0 + + assert calls["url"] == "http://agentmetry.test/api/v1/audit/detections/disposition" + assert calls["headers"] == {"X-API-Key": "secret-token"} + assert calls["json"]["note"] == "known load test" + assert calls["json"]["decided_by"] == "home-lab" + + +def test_disposition_requires_a_note_for_non_resolved_closures(monkeypatch, capsys): + from agentmetry import cli + + def post(*_a, **_kw): + raise AssertionError("should validate before POST") + + monkeypatch.setattr(cli.httpx, "post", post) + + assert main([ + "disposition", + "sess-1", + "credential-exfil", + "--status", + "false_positive", + ]) == 1 + assert "--note is required" in capsys.readouterr().out + + +def test_disposition_says_so_when_the_orchestrator_is_down(monkeypatch, capsys): + from agentmetry import cli + + def boom(*_a, **_kw): + raise RuntimeError("connection refused") + + monkeypatch.setattr(cli.httpx, "post", boom) + + assert main([ + "disposition", + "sess-1", + "credential-exfil", + "--status", + "resolved", + ]) == 1 + assert "Not running" in capsys.readouterr().out + + +def test_disposition_prints_server_errors(monkeypatch, capsys): + from agentmetry import cli + + class Resp: + status_code = 400 + text = "bad request" + + @staticmethod + def json(): + return {"detail": "unknown rule_id 'typo'"} + + monkeypatch.setattr(cli.httpx, "post", lambda *_a, **_kw: Resp()) + + assert main(["disposition", "sess-1", "typo", "--status", "resolved"]) == 1 + assert "unknown rule_id" in capsys.readouterr().out + + # -------------------------------------------------------------------------- # small surfaces that still have an exit-code contract # --------------------------------------------------------------------------