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/recorder.py ADDED
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timedelta, timezone
5
+ from pathlib import Path
6
+ from typing import TextIO
7
+
8
+ from .models import Action, TraceEvent
9
+
10
+
11
+ class Recorder:
12
+ """Small instrumentation helper for emitting AgentAck JSONL traces."""
13
+
14
+ def __init__(self, path: str | Path, session_id: str, *, append: bool = False) -> None:
15
+ self.path = Path(path)
16
+ self.session_id = session_id
17
+ self.path.parent.mkdir(parents=True, exist_ok=True)
18
+ self._handle: TextIO = self.path.open("a" if append else "w", encoding="utf-8", newline="\n")
19
+ self._ended = False
20
+
21
+ def close(self) -> None:
22
+ if not self._handle.closed:
23
+ self._handle.close()
24
+
25
+ def __enter__(self) -> "Recorder":
26
+ return self
27
+
28
+ def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def]
29
+ if not self._ended:
30
+ self.end(reason="recorder context closed" if exc_type is None else "recorder context closed after error")
31
+ self.close()
32
+
33
+ @staticmethod
34
+ def _now() -> datetime:
35
+ return datetime.now(timezone.utc)
36
+
37
+ def _write(self, event: TraceEvent) -> None:
38
+ if self._ended:
39
+ raise RuntimeError("cannot record events after session_end")
40
+ self._handle.write(json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=False))
41
+ self._handle.write("\n")
42
+ self._handle.flush()
43
+
44
+ def propose(self, action_id: str, action: Action, *, intent_id: str | None = None) -> None:
45
+ self._write(
46
+ TraceEvent(
47
+ type="action_proposed",
48
+ timestamp=self._now(),
49
+ session_id=self.session_id,
50
+ action_id=action_id,
51
+ intent_id=intent_id,
52
+ action=action,
53
+ )
54
+ )
55
+
56
+ def request_approval(
57
+ self,
58
+ approval_id: str,
59
+ action_id: str,
60
+ presented_action: Action,
61
+ *,
62
+ intent_id: str | None = None,
63
+ ) -> None:
64
+ self._write(
65
+ TraceEvent(
66
+ type="approval_requested",
67
+ timestamp=self._now(),
68
+ session_id=self.session_id,
69
+ approval_id=approval_id,
70
+ action_id=action_id,
71
+ intent_id=intent_id,
72
+ action=presented_action,
73
+ )
74
+ )
75
+
76
+ def decide(
77
+ self,
78
+ approval_id: str,
79
+ action_id: str,
80
+ decision: str,
81
+ *,
82
+ intent_id: str | None = None,
83
+ ttl_seconds: int | None = None,
84
+ ) -> None:
85
+ if decision not in {"allow", "deny"}:
86
+ raise ValueError("decision must be 'allow' or 'deny'")
87
+ timestamp = self._now()
88
+ self._write(
89
+ TraceEvent(
90
+ type="approval_decision",
91
+ timestamp=timestamp,
92
+ session_id=self.session_id,
93
+ approval_id=approval_id,
94
+ action_id=action_id,
95
+ intent_id=intent_id,
96
+ decision=decision, # type: ignore[arg-type]
97
+ expires_at=(timestamp + timedelta(seconds=ttl_seconds)) if ttl_seconds is not None else None,
98
+ )
99
+ )
100
+
101
+ def execute(
102
+ self,
103
+ action_id: str,
104
+ action: Action,
105
+ *,
106
+ approval_id: str | None = None,
107
+ intent_id: str | None = None,
108
+ ) -> None:
109
+ self._write(
110
+ TraceEvent(
111
+ type="action_executed",
112
+ timestamp=self._now(),
113
+ session_id=self.session_id,
114
+ action_id=action_id,
115
+ approval_id=approval_id,
116
+ intent_id=intent_id,
117
+ action=action,
118
+ )
119
+ )
120
+
121
+ def block(
122
+ self,
123
+ action_id: str,
124
+ *,
125
+ approval_id: str | None = None,
126
+ intent_id: str | None = None,
127
+ reason: str | None = None,
128
+ ) -> None:
129
+ self._write(
130
+ TraceEvent(
131
+ type="action_blocked",
132
+ timestamp=self._now(),
133
+ session_id=self.session_id,
134
+ action_id=action_id,
135
+ approval_id=approval_id,
136
+ intent_id=intent_id,
137
+ reason=reason,
138
+ )
139
+ )
140
+
141
+ def interrupt(self, *, reason: str | None = None) -> None:
142
+ self._write(
143
+ TraceEvent(
144
+ type="interrupt",
145
+ timestamp=self._now(),
146
+ session_id=self.session_id,
147
+ reason=reason,
148
+ )
149
+ )
150
+
151
+ def end(self, *, reason: str | None = None) -> None:
152
+ if self._ended:
153
+ return
154
+ event = TraceEvent(
155
+ type="session_end",
156
+ timestamp=self._now(),
157
+ session_id=self.session_id,
158
+ reason=reason,
159
+ )
160
+ self._handle.write(json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=False))
161
+ self._handle.write("\n")
162
+ self._handle.flush()
163
+ self._ended = True
agentack/report.py ADDED
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from . import __version__
9
+ from .adapters.base import AdapterTestResult
10
+ from .findings import RULES
11
+ from .models import EvaluationReport, Finding, TraceEvent
12
+ from .policy import Policy
13
+ from .provenance import (
14
+ adapter_input_provenance,
15
+ base_provenance,
16
+ trace_action_identities,
17
+ trace_input_provenance,
18
+ )
19
+
20
+
21
+ def render_text(report: EvaluationReport) -> str:
22
+ lines = [f"AgentAck {report.status}"]
23
+ if report.source:
24
+ lines.append(f"Trace: {report.source}")
25
+ lines.append(f"Events: {report.events}")
26
+ lines.append(f"Findings: {len(report.findings)}")
27
+ if not report.findings:
28
+ lines.append("")
29
+ lines.append("Approval-integrity evidence is complete and no enabled rule failed.")
30
+ return "\n".join(lines)
31
+ lines.append("")
32
+ for finding in report.findings:
33
+ location = f" line {finding.line}" if finding.line is not None else ""
34
+ spec = RULES[finding.rule_id]
35
+ lines.append(f"{finding.severity.upper():8} {finding.rule_id} {finding.title}{location}")
36
+ lines.append(f" Evidence: {finding.message}")
37
+ lines.append(f" Why: {spec.description}")
38
+ lines.append(f" Next: {spec.remediation}")
39
+ if report.status == "INCOMPLETE":
40
+ lines.append("")
41
+ lines.append("Result is INCOMPLETE because the trace lacks evidence required to establish approval integrity.")
42
+ return "\n".join(lines)
43
+
44
+
45
+ def _finding_payload(finding: Finding) -> dict[str, Any]:
46
+ spec = RULES[finding.rule_id]
47
+ payload = finding.to_dict()
48
+ payload["why"] = spec.description
49
+ payload["remediation"] = spec.remediation
50
+ return payload
51
+
52
+
53
+ def trace_report_payload(
54
+ report: EvaluationReport,
55
+ events: list[TraceEvent],
56
+ policy: Policy,
57
+ *,
58
+ trace_sha256: str,
59
+ trace_source: str | Path | None,
60
+ policy_source: str | Path | None = None,
61
+ run_id: str | None = None,
62
+ evaluated_at: str | None = None,
63
+ ) -> dict[str, Any]:
64
+ session_id = events[0].session_id if events else None
65
+ payload = base_provenance(
66
+ agentack_version=__version__,
67
+ kind="trace",
68
+ session_id=session_id,
69
+ run_id=run_id,
70
+ evaluated_at=evaluated_at,
71
+ )
72
+ payload.update(
73
+ {
74
+ "input": trace_input_provenance(
75
+ trace_sha256=trace_sha256,
76
+ policy=policy,
77
+ trace_source=trace_source,
78
+ policy_source=policy_source,
79
+ ),
80
+ "adapter": None,
81
+ "actions": [item.to_dict() for item in trace_action_identities(events)],
82
+ "result": {
83
+ "status": report.status,
84
+ "event_count": report.events,
85
+ "finding_count": len(report.findings),
86
+ "rule_counts": report.rule_counts,
87
+ "findings": [_finding_payload(item) for item in report.findings],
88
+ },
89
+ }
90
+ )
91
+ return payload
92
+
93
+
94
+ def adapter_report_payload(
95
+ result: AdapterTestResult,
96
+ *,
97
+ run_id: str | None = None,
98
+ evaluated_at: str | None = None,
99
+ ) -> dict[str, Any]:
100
+ payload = base_provenance(
101
+ agentack_version=__version__,
102
+ kind="adapter",
103
+ session_id=result.session_id,
104
+ run_id=run_id,
105
+ evaluated_at=evaluated_at,
106
+ )
107
+ payload.update(
108
+ {
109
+ "input": adapter_input_provenance(result.evidence_sha256),
110
+ "adapter": {
111
+ "name": result.adapter,
112
+ "display_name": result.display_name,
113
+ "version": result.adapter_version,
114
+ },
115
+ "actions": [item.to_dict() for item in result.actions],
116
+ "result": {
117
+ "status": result.status,
118
+ "checks": [check.to_dict() for check in result.checks],
119
+ "notes": list(result.notes),
120
+ },
121
+ }
122
+ )
123
+ return payload
124
+
125
+
126
+ def write_json_report(path: str | Path, payload: dict[str, Any]) -> None:
127
+ target = Path(path)
128
+ target.parent.mkdir(parents=True, exist_ok=True)
129
+ target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
130
+
131
+
132
+ def _sarif_level(severity: str) -> str:
133
+ return {"critical": "error", "high": "error", "medium": "warning", "low": "note"}[severity]
134
+
135
+
136
+ def _run_properties(document: dict[str, Any]) -> dict[str, Any]:
137
+ run = document["run"]
138
+ input_data = document["input"]
139
+ trace = input_data.get("trace") or {}
140
+ policy = input_data.get("policy") or {}
141
+ evidence = input_data.get("evidence") or {}
142
+ adapter = document.get("adapter") or {}
143
+ return {
144
+ "agentackReportSchemaVersion": document["report_schema_version"],
145
+ "runId": run["run_id"],
146
+ "evaluationKind": run["kind"],
147
+ "evaluatedAt": run["evaluated_at"],
148
+ "sessionId": run.get("session_id"),
149
+ "traceSchemaVersion": trace.get("schema_version"),
150
+ "traceSha256": trace.get("sha256"),
151
+ "policySha256": policy.get("sha256"),
152
+ "evidenceSha256": evidence.get("sha256"),
153
+ "adapterName": adapter.get("name"),
154
+ "adapterVersion": adapter.get("version"),
155
+ "status": document["result"]["status"],
156
+ "actionIdentities": document["actions"],
157
+ }
158
+
159
+
160
+ def trace_sarif_payload(report: EvaluationReport, document: dict[str, Any]) -> dict[str, Any]:
161
+ rules = []
162
+ for rule_id, spec in RULES.items():
163
+ rules.append(
164
+ {
165
+ "id": rule_id,
166
+ "name": spec.title.replace(" ", ""),
167
+ "shortDescription": {"text": spec.title},
168
+ "fullDescription": {"text": spec.description},
169
+ "help": {"text": spec.remediation},
170
+ "defaultConfiguration": {"level": _sarif_level(spec.severity)},
171
+ "properties": {"standards": list(spec.standards)},
172
+ }
173
+ )
174
+ results = []
175
+ source_name = (document["input"].get("trace") or {}).get("source")
176
+ for finding in report.findings:
177
+ spec = RULES[finding.rule_id]
178
+ result: dict[str, Any] = {
179
+ "ruleId": finding.rule_id,
180
+ "level": _sarif_level(finding.severity),
181
+ "message": {"text": finding.message},
182
+ "properties": {
183
+ "actionId": finding.action_id,
184
+ "approvalId": finding.approval_id,
185
+ "standards": list(finding.standards),
186
+ "remediation": spec.remediation,
187
+ },
188
+ }
189
+ if source_name and finding.line is not None:
190
+ result["locations"] = [
191
+ {
192
+ "physicalLocation": {
193
+ "artifactLocation": {"uri": source_name},
194
+ "region": {"startLine": finding.line},
195
+ }
196
+ }
197
+ ]
198
+ results.append(result)
199
+ return {
200
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
201
+ "version": "2.1.0",
202
+ "runs": [
203
+ {
204
+ "automationDetails": {"id": document["run"]["run_id"]},
205
+ "tool": {
206
+ "driver": {
207
+ "name": "AgentAck",
208
+ "version": __version__,
209
+ "informationUri": "https://github.com/sylvesterkaczmarek/agentack",
210
+ "rules": rules,
211
+ }
212
+ },
213
+ "properties": _run_properties(document),
214
+ "results": results,
215
+ }
216
+ ],
217
+ }
218
+
219
+
220
+ def _adapter_rule_id(label: str) -> str:
221
+ slug = re.sub(r"[^A-Z0-9]+", "_", label.upper()).strip("_")
222
+ return f"ADAPTER_{slug}"
223
+
224
+
225
+ def adapter_sarif_payload(result: AdapterTestResult, document: dict[str, Any]) -> dict[str, Any]:
226
+ rules = []
227
+ results = []
228
+ for check in result.checks:
229
+ rule_id = _adapter_rule_id(check.label)
230
+ rules.append(
231
+ {
232
+ "id": rule_id,
233
+ "name": rule_id,
234
+ "shortDescription": {"text": check.label},
235
+ }
236
+ )
237
+ if check.status not in {"FAIL", "INCOMPLETE"}:
238
+ continue
239
+ results.append(
240
+ {
241
+ "ruleId": rule_id,
242
+ "level": "error" if check.status == "FAIL" else "warning",
243
+ "message": {"text": check.detail},
244
+ "properties": {"checkStatus": check.status},
245
+ }
246
+ )
247
+ return {
248
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
249
+ "version": "2.1.0",
250
+ "runs": [
251
+ {
252
+ "automationDetails": {"id": document["run"]["run_id"]},
253
+ "tool": {
254
+ "driver": {
255
+ "name": "AgentAck",
256
+ "version": __version__,
257
+ "informationUri": "https://github.com/sylvesterkaczmarek/agentack",
258
+ "rules": rules,
259
+ }
260
+ },
261
+ "properties": _run_properties(document),
262
+ "results": results,
263
+ }
264
+ ],
265
+ }
266
+
267
+
268
+ def write_sarif(path: str | Path, payload: dict[str, Any]) -> None:
269
+ target = Path(path)
270
+ target.parent.mkdir(parents=True, exist_ok=True)
271
+ target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
agentack/ux.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from .adapters.base import AdapterStatus, AdapterTestResult
6
+
7
+
8
+ def _line(label: str, status: str, width: int = 28) -> str:
9
+ return f"{label:<{width}} {status}"
10
+
11
+
12
+ def render_adapter_test(result: AdapterTestResult) -> str:
13
+ lines = [f"AgentAck {result.status}", f"Integration: {result.display_name}", ""]
14
+ for check in result.checks:
15
+ lines.append(_line(check.label, check.status))
16
+ problems = [check for check in result.checks if check.status in {"FAIL", "INCOMPLETE"}]
17
+ if problems:
18
+ lines.append("")
19
+ lines.append("Details")
20
+ for check in problems:
21
+ lines.append(f"- {check.label}: {check.detail}")
22
+ if result.notes:
23
+ lines.append("")
24
+ lines.append("Notes")
25
+ lines.extend(f"- {note}" for note in result.notes)
26
+ return "\n".join(lines)
27
+
28
+
29
+ def render_doctor(statuses: Iterable[AdapterStatus], discovered: Iterable[tuple[str, str, str | None]]) -> str:
30
+ status_list = list(statuses)
31
+ lines = ["AgentAck doctor", ""]
32
+ for status in status_list:
33
+ state = "READY" if status.testable else "NOT FOUND" if not status.installed else "DETECTED"
34
+ extra = status.version or status.executable or ""
35
+ lines.append(f"{status.display_name:<20} {state:<10} {extra}".rstrip())
36
+ if status.detail:
37
+ lines.append(f" {status.detail}")
38
+ for display_name, state, detail in discovered:
39
+ lines.append(f"{display_name:<20} {state:<10}".rstrip())
40
+ if detail:
41
+ lines.append(f" {detail}")
42
+ ready = [status.name for status in status_list if status.testable]
43
+ lines.append("")
44
+ if ready:
45
+ lines.append("Run: " + " | ".join(f"agentack test {name}" for name in ready))
46
+ else:
47
+ lines.append("No live AgentAck adapter is ready on this machine. `agentack demo` still works without an agent account.")
48
+ return "\n".join(lines)