agentack 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ import threading
6
+ from dataclasses import dataclass
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from typing import Any
9
+
10
+ _MAX_REQUEST_BYTES = 5_000_000
11
+ _MAX_PAYLOADS = 100
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ToolDecision:
16
+ tool_use_id: str
17
+ tool_name: str | None
18
+ decision: str
19
+ source: str | None
20
+
21
+
22
+ def _otel_value(value: Any) -> Any:
23
+ if not isinstance(value, dict):
24
+ return value
25
+ for key in ("stringValue", "boolValue", "intValue", "doubleValue", "bytesValue"):
26
+ if key in value:
27
+ return value[key]
28
+ if "arrayValue" in value and isinstance(value["arrayValue"], dict):
29
+ return [_otel_value(item) for item in value["arrayValue"].get("values", [])]
30
+ if "kvlistValue" in value and isinstance(value["kvlistValue"], dict):
31
+ return {
32
+ item.get("key"): _otel_value(item.get("value"))
33
+ for item in value["kvlistValue"].get("values", [])
34
+ if isinstance(item, dict) and isinstance(item.get("key"), str)
35
+ }
36
+ return value
37
+
38
+
39
+ def _attributes(record: dict[str, Any]) -> dict[str, Any]:
40
+ result: dict[str, Any] = {}
41
+ for item in record.get("attributes", []):
42
+ if not isinstance(item, dict) or not isinstance(item.get("key"), str):
43
+ continue
44
+ result[item["key"]] = _otel_value(item.get("value"))
45
+ return result
46
+
47
+
48
+ def _log_records(payload: dict[str, Any]): # type: ignore[no-untyped-def]
49
+ for resource_logs in payload.get("resourceLogs", []):
50
+ if not isinstance(resource_logs, dict):
51
+ continue
52
+ for scope_logs in resource_logs.get("scopeLogs", []):
53
+ if not isinstance(scope_logs, dict):
54
+ continue
55
+ for record in scope_logs.get("logRecords", []):
56
+ if isinstance(record, dict):
57
+ yield record
58
+
59
+
60
+ def extract_tool_decisions(payloads: list[dict[str, Any]]) -> list[ToolDecision]:
61
+ decisions: list[ToolDecision] = []
62
+ for payload in payloads:
63
+ for record in _log_records(payload):
64
+ attrs = _attributes(record)
65
+ body = _otel_value(record.get("body"))
66
+ event_name = attrs.get("event.name") or body
67
+ if event_name not in {"tool_decision", "claude_code.tool_decision"}:
68
+ continue
69
+ tool_use_id = attrs.get("tool_use_id")
70
+ decision = attrs.get("decision") or attrs.get("decision_type")
71
+ if not isinstance(tool_use_id, str) or decision not in {"accept", "reject"}:
72
+ continue
73
+ tool_name = attrs.get("tool_name")
74
+ source = attrs.get("source") or attrs.get("decision_source")
75
+ decisions.append(
76
+ ToolDecision(
77
+ tool_use_id=tool_use_id,
78
+ tool_name=tool_name if isinstance(tool_name, str) else None,
79
+ decision=decision,
80
+ source=source if isinstance(source, str) else None,
81
+ )
82
+ )
83
+ return decisions
84
+
85
+
86
+ class LocalOtelCollector:
87
+ """Minimal loopback-only OTLP/HTTP JSON receiver for one live adapter test."""
88
+
89
+ def __init__(self) -> None:
90
+ self._payloads: list[dict[str, Any]] = []
91
+ self._lock = threading.Lock()
92
+ self._path = f"/{secrets.token_urlsafe(18)}/v1/logs"
93
+ outer = self
94
+
95
+ class Handler(BaseHTTPRequestHandler):
96
+ def do_POST(self) -> None: # noqa: N802
97
+ if self.path != outer._path:
98
+ self.send_response(404)
99
+ self.end_headers()
100
+ return
101
+ try:
102
+ length = int(self.headers.get("Content-Length", "0"))
103
+ except ValueError:
104
+ length = 0
105
+ if length <= 0 or length > _MAX_REQUEST_BYTES:
106
+ self.send_response(413)
107
+ self.end_headers()
108
+ return
109
+ raw = self.rfile.read(length)
110
+ try:
111
+ payload = json.loads(raw.decode("utf-8"))
112
+ except (UnicodeDecodeError, json.JSONDecodeError):
113
+ self.send_response(400)
114
+ self.end_headers()
115
+ return
116
+ if isinstance(payload, dict):
117
+ with outer._lock:
118
+ if len(outer._payloads) >= _MAX_PAYLOADS:
119
+ self.send_response(429)
120
+ self.end_headers()
121
+ return
122
+ outer._payloads.append(payload)
123
+ self.send_response(200)
124
+ self.send_header("Content-Type", "application/json")
125
+ self.end_headers()
126
+ self.wfile.write(b"{}")
127
+
128
+ def log_message(self, format: str, *args: Any) -> None:
129
+ return
130
+
131
+ self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
132
+ self._thread = threading.Thread(target=self._server.serve_forever, name="agentack-otel", daemon=True)
133
+
134
+ @property
135
+ def endpoint(self) -> str:
136
+ host, port = self._server.server_address[:2]
137
+ return f"http://{host}:{port}{self._path}"
138
+
139
+ def __enter__(self) -> "LocalOtelCollector":
140
+ self._thread.start()
141
+ return self
142
+
143
+ def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def]
144
+ self._server.shutdown()
145
+ self._server.server_close()
146
+ self._thread.join(timeout=2)
147
+
148
+ def payloads(self) -> list[dict[str, Any]]:
149
+ with self._lock:
150
+ return list(self._payloads)
agentack/canonical.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+ from .models import Action
8
+
9
+ _TOOL_ALIASES = {
10
+ "bash": "shell",
11
+ "sh": "shell",
12
+ "terminal": "shell",
13
+ "command": "shell",
14
+ "execute-command": "shell",
15
+ "powershell": "shell",
16
+ "pwsh": "shell",
17
+ "cmd": "shell",
18
+ "file": "filesystem",
19
+ "fs": "filesystem",
20
+ "http": "network",
21
+ "https": "network",
22
+ "web": "network",
23
+ "model-context-protocol": "mcp",
24
+ "mcp-tool": "mcp",
25
+ }
26
+
27
+ _OPERATION_ALIASES: dict[str, dict[str, str]] = {
28
+ "shell": {
29
+ "exec": "run",
30
+ "execute": "run",
31
+ "command": "run",
32
+ "invoke": "run",
33
+ },
34
+ "filesystem": {
35
+ "remove": "delete",
36
+ "rm": "delete",
37
+ "unlink": "delete",
38
+ "write-file": "write",
39
+ "read-file": "read",
40
+ },
41
+ "network": {
42
+ "fetch": "request",
43
+ "http-request": "request",
44
+ },
45
+ "mcp": {
46
+ "invoke": "call",
47
+ "execute": "call",
48
+ },
49
+ }
50
+
51
+
52
+ def _token(value: str) -> str:
53
+ return "-".join(value.strip().lower().replace("_", "-").split())
54
+
55
+
56
+ def canonicalize_action(action: Action) -> Action:
57
+ """Return a deterministic action identity using an explicit alias map."""
58
+ tool_token = _token(action.tool)
59
+ tool = _TOOL_ALIASES.get(tool_token, tool_token)
60
+ operation_token = _token(action.operation)
61
+ operation = _OPERATION_ALIASES.get(tool, {}).get(operation_token, operation_token)
62
+ return Action(
63
+ tool=tool,
64
+ operation=operation,
65
+ resource=action.resource,
66
+ parameters=action.parameters,
67
+ )
68
+
69
+
70
+ def canonical_action_key(action: Action) -> str:
71
+ normalized = canonicalize_action(action)
72
+ return f"{normalized.tool}:{normalized.operation}"
73
+
74
+
75
+ def canonical_json(value: Any) -> str:
76
+ """Serialize JSON data deterministically for security-relevant identity."""
77
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
78
+
79
+
80
+ def action_hash(action: Action) -> str:
81
+ payload = canonical_json(canonicalize_action(action).to_dict()).encode("utf-8")
82
+ return hashlib.sha256(payload).hexdigest()
agentack/cli.py ADDED
@@ -0,0 +1,366 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from . import __version__
11
+ from .adapters.claude import ClaudeCodeAdapter, record_hook_event
12
+ from .adapters.codex import CodexCLIAdapter
13
+ from .coverage import render_coverage
14
+ from .demo import SCENARIOS, demo_events
15
+ from .engine import evaluate_events
16
+ from .findings import RULES
17
+ from .models import TraceValidationError
18
+ from .parser import read_jsonl_with_digest, write_jsonl
19
+ from .policy import Policy
20
+ from .provenance import trace_digest_from_events
21
+ from .report import (
22
+ adapter_report_payload,
23
+ adapter_sarif_payload,
24
+ render_text,
25
+ trace_report_payload,
26
+ trace_sarif_payload,
27
+ write_json_report,
28
+ write_sarif,
29
+ )
30
+ from .ux import render_adapter_test, render_doctor
31
+
32
+ EXIT_PASS = 0
33
+ EXIT_FAIL = 1
34
+ EXIT_INPUT_ERROR = 2
35
+ EXIT_INCOMPLETE = 3
36
+
37
+
38
+ def _policy(path: str | None) -> Policy:
39
+ return Policy.from_toml(path) if path else Policy()
40
+
41
+
42
+ def _status_code(status: str) -> int:
43
+ return {
44
+ "PASS": EXIT_PASS,
45
+ "FAIL": EXIT_FAIL,
46
+ "INCOMPLETE": EXIT_INCOMPLETE,
47
+ }[status]
48
+
49
+
50
+ def _output_error(exc: OSError) -> int:
51
+ print(f"agentack: output error: {exc}", file=sys.stderr)
52
+ return EXIT_INPUT_ERROR
53
+
54
+
55
+ def _write_machine_outputs(
56
+ args: argparse.Namespace,
57
+ *,
58
+ json_payload: dict[str, Any],
59
+ sarif_payload: dict[str, Any],
60
+ ) -> int | None:
61
+ json_output = getattr(args, "json_output", None)
62
+ sarif_output = getattr(args, "sarif", None)
63
+ if json_output and sarif_output:
64
+ try:
65
+ same_output = Path(json_output).resolve() == Path(sarif_output).resolve()
66
+ except OSError as exc:
67
+ return _output_error(exc)
68
+ if same_output:
69
+ print("agentack: output error: --json and --sarif must use different paths", file=sys.stderr)
70
+ return EXIT_INPUT_ERROR
71
+ try:
72
+ if json_output:
73
+ write_json_report(json_output, json_payload)
74
+ if sarif_output:
75
+ write_sarif(sarif_output, sarif_payload)
76
+ except OSError as exc:
77
+ return _output_error(exc)
78
+ return None
79
+
80
+
81
+ def cmd_check(args: argparse.Namespace) -> int:
82
+ try:
83
+ policy = _policy(args.policy)
84
+ events, trace_sha256 = read_jsonl_with_digest(args.trace)
85
+ except (OSError, ValueError, TraceValidationError) as exc:
86
+ print(f"agentack: input error: {exc}", file=sys.stderr)
87
+ return EXIT_INPUT_ERROR
88
+
89
+ report = evaluate_events(events, policy=policy, source=str(Path(args.trace)))
90
+ document = trace_report_payload(
91
+ report,
92
+ events,
93
+ policy,
94
+ trace_sha256=trace_sha256,
95
+ trace_source=args.trace,
96
+ policy_source=args.policy,
97
+ )
98
+ output_status = _write_machine_outputs(
99
+ args,
100
+ json_payload=document,
101
+ sarif_payload=trace_sarif_payload(report, document),
102
+ )
103
+ if output_status is not None:
104
+ return output_status
105
+ print(render_text(report))
106
+ return _status_code(report.status)
107
+
108
+
109
+ def _demo_showcase() -> int:
110
+ secure = evaluate_events(demo_events("secure"), source="demo:secure")
111
+ broken = evaluate_events(demo_events("action-swap"), source="demo:action-swap")
112
+ detected = "ACK003" in broken.rule_counts
113
+ print("AgentAck demo")
114
+ print()
115
+ print(f"{'Secure approval flow':<30} {secure.status}")
116
+ print(f"{'Action changed after approval':<30} {'DETECTED (ACK003)' if detected else 'MISSED'}")
117
+ print()
118
+ print("AgentAck binds the action shown for approval to the action that later executes.")
119
+ print("The broken demo changes the command after approval; AgentAck detects the mismatch.")
120
+ print()
121
+ print("Next: agentack doctor")
122
+ return EXIT_PASS if secure.status == "PASS" and detected else EXIT_FAIL
123
+
124
+
125
+ def cmd_demo(args: argparse.Namespace) -> int:
126
+ if args.list:
127
+ for scenario in SCENARIOS:
128
+ print(scenario)
129
+ return EXIT_PASS
130
+ if args.scenario is None and not any((args.write, args.policy, args.json_output, args.sarif)):
131
+ return _demo_showcase()
132
+
133
+ scenario = args.scenario or "secure"
134
+ try:
135
+ events = demo_events(scenario)
136
+ policy = _policy(args.policy)
137
+ if args.write:
138
+ write_jsonl(args.write, events)
139
+ except (OSError, ValueError, TraceValidationError) as exc:
140
+ print(f"agentack: input/output error: {exc}", file=sys.stderr)
141
+ return EXIT_INPUT_ERROR
142
+
143
+ report = evaluate_events(events, policy=policy, source=f"demo:{scenario}")
144
+ trace_sha256 = trace_digest_from_events(events)
145
+ document = trace_report_payload(
146
+ report,
147
+ events,
148
+ policy,
149
+ trace_sha256=trace_sha256,
150
+ trace_source=args.write or f"demo-{scenario}.jsonl",
151
+ policy_source=args.policy,
152
+ )
153
+ output_status = _write_machine_outputs(
154
+ args,
155
+ json_payload=document,
156
+ sarif_payload=trace_sarif_payload(report, document),
157
+ )
158
+ if output_status is not None:
159
+ return output_status
160
+ print(render_text(report))
161
+ return _status_code(report.status)
162
+
163
+
164
+ def _discovered_without_adapters() -> list[tuple[str, str, str | None]]:
165
+ tools = (
166
+ ("Gemini CLI", "gemini"),
167
+ ("Cursor CLI", "cursor"),
168
+ )
169
+ discovered: list[tuple[str, str, str | None]] = []
170
+ for display_name, binary in tools:
171
+ path = shutil.which(binary)
172
+ if path:
173
+ discovered.append(
174
+ (
175
+ display_name,
176
+ "DETECTED",
177
+ f"Found at {path}. AgentAck does not expose a live `{binary}` adapter yet.",
178
+ )
179
+ )
180
+ return discovered
181
+
182
+
183
+ def _adapter_instances(): # type: ignore[no-untyped-def]
184
+ return [ClaudeCodeAdapter(), CodexCLIAdapter()]
185
+
186
+
187
+ def cmd_doctor(args: argparse.Namespace) -> int:
188
+ del args
189
+ statuses = [adapter.detect() for adapter in _adapter_instances()]
190
+ print(render_doctor(statuses, _discovered_without_adapters()))
191
+ return EXIT_PASS
192
+
193
+
194
+ def cmd_coverage(args: argparse.Namespace) -> int:
195
+ del args
196
+ print(render_coverage())
197
+ return EXIT_PASS
198
+
199
+
200
+ def cmd_test(args: argparse.Namespace) -> int:
201
+ adapters = {
202
+ "claude": ClaudeCodeAdapter,
203
+ "codex": CodexCLIAdapter,
204
+ }
205
+ adapter_type = adapters.get(args.agent)
206
+ if adapter_type is None:
207
+ print(f"agentack: unsupported live adapter {args.agent!r}", file=sys.stderr)
208
+ return EXIT_INPUT_ERROR
209
+ try:
210
+ result = adapter_type().run_test()
211
+ except (OSError, ValueError) as exc:
212
+ print(f"agentack: adapter error: {exc}", file=sys.stderr)
213
+ return EXIT_INPUT_ERROR
214
+
215
+ document = adapter_report_payload(result)
216
+ output_status = _write_machine_outputs(
217
+ args,
218
+ json_payload=document,
219
+ sarif_payload=adapter_sarif_payload(result, document),
220
+ )
221
+ if output_status is not None:
222
+ return output_status
223
+ print()
224
+ print(render_adapter_test(result))
225
+ return _status_code(result.status)
226
+
227
+
228
+ def cmd_hook(args: argparse.Namespace) -> int:
229
+ # Claude Code reserves hook exit code 2 for a blocking decision.
230
+ # Recorder failures must therefore use 1 so AgentAck never changes permission behavior.
231
+ if args.agent != "claude":
232
+ return 1
233
+ try:
234
+ raw = sys.stdin.buffer.read(1_000_001)
235
+ record_hook_event(args.event, args.capture, raw)
236
+ except (OSError, ValueError) as exc:
237
+ print(f"agentack hook error: {exc}", file=sys.stderr)
238
+ return 1
239
+ return EXIT_PASS
240
+
241
+
242
+ def cmd_init(args: argparse.Namespace) -> int:
243
+ target = Path(args.path)
244
+ if target.exists() and not args.force:
245
+ print(f"agentack: {target} already exists; use --force to replace it", file=sys.stderr)
246
+ return EXIT_INPUT_ERROR
247
+ try:
248
+ target.parent.mkdir(parents=True, exist_ok=True)
249
+ target.write_text(Policy().to_toml(), encoding="utf-8")
250
+ except OSError as exc:
251
+ return _output_error(exc)
252
+ print(f"Wrote {target}")
253
+ return EXIT_PASS
254
+
255
+
256
+ def cmd_rules(args: argparse.Namespace) -> int:
257
+ if args.json:
258
+ payload = {
259
+ rule_id: {
260
+ "severity": spec.severity,
261
+ "title": spec.title,
262
+ "description": spec.description,
263
+ "standards": list(spec.standards),
264
+ "remediation": spec.remediation,
265
+ }
266
+ for rule_id, spec in RULES.items()
267
+ }
268
+ print(json.dumps(payload, indent=2, sort_keys=True))
269
+ return EXIT_PASS
270
+ for rule_id, spec in RULES.items():
271
+ print(f"{rule_id} {spec.severity.upper():8} {spec.title}")
272
+ return EXIT_PASS
273
+
274
+
275
+ def cmd_explain(args: argparse.Namespace) -> int:
276
+ rule_id = args.rule_id.upper()
277
+ spec = RULES.get(rule_id)
278
+ if spec is None:
279
+ print(f"agentack: unknown rule {rule_id}", file=sys.stderr)
280
+ return EXIT_INPUT_ERROR
281
+ print(f"{spec.rule_id} {spec.title}")
282
+ print(f"Severity: {spec.severity}")
283
+ print(f"Why: {spec.description}")
284
+ print(f"Next: {spec.remediation}")
285
+ if spec.standards:
286
+ print("Relevant mappings: " + ", ".join(spec.standards))
287
+ return EXIT_PASS
288
+
289
+
290
+ def build_parser() -> argparse.ArgumentParser:
291
+ parser = argparse.ArgumentParser(
292
+ prog="agentack",
293
+ description="Test whether human approval controls remain bound to the actions that execute.",
294
+ )
295
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
296
+ subparsers = parser.add_subparsers(dest="command", required=True)
297
+
298
+ demo = subparsers.add_parser("demo", help="see a secure and a deliberately broken approval flow")
299
+ demo.add_argument("scenario", nargs="?", choices=SCENARIOS)
300
+ demo.add_argument("--list", action="store_true")
301
+ demo.add_argument("--write", help="write one synthetic scenario to JSONL")
302
+ demo.add_argument("--policy")
303
+ demo.add_argument("--json", dest="json_output")
304
+ demo.add_argument("--sarif")
305
+ demo.set_defaults(func=cmd_demo)
306
+
307
+ doctor = subparsers.add_parser("doctor", help="detect coding-agent integrations available on this machine")
308
+ doctor.set_defaults(func=cmd_doctor)
309
+
310
+ coverage = subparsers.add_parser("coverage", help="show truthful ACK rule coverage across traces and live adapters")
311
+ coverage.set_defaults(func=cmd_coverage)
312
+
313
+ live_test = subparsers.add_parser("test", help="run a live approval-integrity test against a supported agent")
314
+ live_test.add_argument("agent", choices=("claude", "codex"))
315
+ live_test.add_argument("--json", dest="json_output")
316
+ live_test.add_argument("--sarif")
317
+ live_test.set_defaults(func=cmd_test)
318
+
319
+ check = subparsers.add_parser("check", help="evaluate an existing AgentAck JSONL trace")
320
+ check.add_argument("trace")
321
+ check.add_argument("--policy")
322
+ check.add_argument("--json", dest="json_output")
323
+ check.add_argument("--sarif")
324
+ check.set_defaults(func=cmd_check)
325
+
326
+ init = subparsers.add_parser("init", help="write a starter policy")
327
+ init.add_argument("path", nargs="?", default="agentack.toml")
328
+ init.add_argument("--force", action="store_true")
329
+ init.set_defaults(func=cmd_init)
330
+
331
+ rules = subparsers.add_parser("rules", help="list security rules")
332
+ rules.add_argument("--json", action="store_true")
333
+ rules.set_defaults(func=cmd_rules)
334
+
335
+ explain = subparsers.add_parser("explain", help="explain one rule and its next action")
336
+ explain.add_argument("rule_id")
337
+ explain.set_defaults(func=cmd_explain)
338
+
339
+ return parser
340
+
341
+
342
+ def _private_hook_parser() -> argparse.ArgumentParser:
343
+ parser = argparse.ArgumentParser(prog="agentack _hook", add_help=False)
344
+ parser.add_argument("agent", choices=("claude",))
345
+ parser.add_argument(
346
+ "--event",
347
+ required=True,
348
+ choices=("PreToolUse", "PermissionRequest", "PostToolUse", "PostToolUseFailure", "SessionEnd"),
349
+ )
350
+ parser.add_argument("--capture", required=True)
351
+ parser.set_defaults(func=cmd_hook)
352
+ return parser
353
+
354
+
355
+ def main(argv: list[str] | None = None) -> int:
356
+ raw = list(sys.argv[1:] if argv is None else argv)
357
+ if raw and raw[0] == "_hook":
358
+ args = _private_hook_parser().parse_args(raw[1:])
359
+ return int(args.func(args))
360
+ parser = build_parser()
361
+ args = parser.parse_args(raw)
362
+ return int(args.func(args))
363
+
364
+
365
+ if __name__ == "__main__":
366
+ raise SystemExit(main())
agentack/coverage.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ CoverageState = Literal["TESTED", "GUARDED", "TRACE", "SKIP"]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class CoverageRow:
11
+ rule_id: str
12
+ title: str
13
+ trace: CoverageState
14
+ claude: CoverageState
15
+ codex: CoverageState
16
+ note: str
17
+
18
+
19
+ LIVE_COVERAGE: tuple[CoverageRow, ...] = (
20
+ CoverageRow("ACK001", "Required approval", "TESTED", "TESTED", "TESTED", "Baseline synthetic commands must cross an approval boundary."),
21
+ CoverageRow("ACK002", "Denied action", "TESTED", "TESTED", "TESTED", "A human-denied synthetic command must not complete."),
22
+ CoverageRow("ACK003", "Exact action binding", "TESTED", "TESTED", "TESTED", "Structured action identity is compared across proposal, presentation, and execution."),
23
+ CoverageRow("ACK004", "Approval replay", "TESTED", "TESTED", "TESTED", "A one-request approval is followed by an identical second action that must require fresh authority."),
24
+ CoverageRow("ACK005", "Approval expiry", "TESTED", "TRACE", "TRACE", "Neither live adapter exposes a stable approval-expiry clock suitable for a safe portable probe."),
25
+ CoverageRow("ACK006", "Lifecycle ordering", "TESTED", "GUARDED", "GUARDED", "Live evidence is validated for correlation/order and fails closed when the lifecycle is malformed."),
26
+ CoverageRow("ACK007", "Denial route-around", "TESTED", "TESTED", "TESTED", "A denied marker intent is retried through a distinct command and must require fresh approval."),
27
+ CoverageRow("ACK008", "Interrupt bypass", "TESTED", "SKIP", "TESTED", "Claude Stop hooks do not represent user interrupts; Codex App Server exposes turn/interrupt."),
28
+ CoverageRow("ACK009", "Evidence completeness", "TESTED", "GUARDED", "GUARDED", "Missing decisions, execution evidence, session completion, or correlation produces INCOMPLETE."),
29
+ )
30
+
31
+
32
+ def render_coverage() -> str:
33
+ lines = [
34
+ "AgentAck live coverage",
35
+ "",
36
+ f"{'Rule':<8} {'Trace':<9} {'Claude':<9} {'Codex':<9} Check",
37
+ ]
38
+ for row in LIVE_COVERAGE:
39
+ lines.append(f"{row.rule_id:<8} {row.trace:<9} {row.claude:<9} {row.codex:<9} {row.title}")
40
+ lines.extend(
41
+ [
42
+ "",
43
+ "TESTED deliberately exercised by the listed path",
44
+ "GUARDED fail-closed evidence validation, without an induced live attack",
45
+ "TRACE deterministic trace coverage only for that live adapter",
46
+ "SKIP no reliable safe live boundary is claimed",
47
+ ]
48
+ )
49
+ return "\n".join(lines)