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.
- agentack/__init__.py +22 -0
- agentack/__main__.py +3 -0
- agentack/adapters/__init__.py +14 -0
- agentack/adapters/base.py +66 -0
- agentack/adapters/claude.py +38 -0
- agentack/adapters/claude_analysis.py +342 -0
- agentack/adapters/claude_capture.py +112 -0
- agentack/adapters/claude_live.py +186 -0
- agentack/adapters/codex.py +173 -0
- agentack/adapters/codex_analysis.py +237 -0
- agentack/adapters/codex_protocol.py +549 -0
- agentack/adapters/otel.py +150 -0
- agentack/canonical.py +82 -0
- agentack/cli.py +366 -0
- agentack/coverage.py +49 -0
- agentack/demo.py +106 -0
- agentack/engine.py +279 -0
- agentack/findings.py +91 -0
- agentack/models.py +303 -0
- agentack/parser.py +73 -0
- agentack/policy.py +78 -0
- agentack/provenance.py +168 -0
- agentack/recorder.py +163 -0
- agentack/report.py +271 -0
- agentack/ux.py +48 -0
- agentack-0.6.0.dist-info/METADATA +263 -0
- agentack-0.6.0.dist-info/RECORD +31 -0
- agentack-0.6.0.dist-info/WHEEL +5 -0
- agentack-0.6.0.dist-info/entry_points.txt +2 -0
- agentack-0.6.0.dist-info/licenses/LICENSE +21 -0
- agentack-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|