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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ------ | ------- |
Expand All @@ -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
Expand Down Expand Up @@ -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 <correlation_id> <rule_id>` | Close a detection through the API with `--status resolved\|false_positive\|risk_accepted`; false positives and accepted risks require `--note` |
| `agentmetry replay <correlation_id>` | 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) |
Expand Down
55 changes: 55 additions & 0 deletions apps/orchestrator/agentmetry/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,23 @@
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:
display = host if host != "0.0.0.0" else "127.0.0.1" # noqa: S104
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:
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
132 changes: 132 additions & 0 deletions apps/orchestrator/tests/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# --------------------------------------------------------------------------
Expand Down
Loading