powerailabs-acttrace 0.1.0__tar.gz
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.
- powerailabs_acttrace-0.1.0/.gitignore +16 -0
- powerailabs_acttrace-0.1.0/PKG-INFO +50 -0
- powerailabs_acttrace-0.1.0/README.md +40 -0
- powerailabs_acttrace-0.1.0/pyproject.toml +19 -0
- powerailabs_acttrace-0.1.0/src/powerailabs/acttrace/__init__.py +234 -0
- powerailabs_acttrace-0.1.0/src/powerailabs/acttrace/cli.py +31 -0
- powerailabs_acttrace-0.1.0/src/powerailabs/acttrace/py.typed +0 -0
- powerailabs_acttrace-0.1.0/tests/test_acttrace.py +118 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: powerailabs-acttrace
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit: a tamper-evident, hash-chained, auto-populated record of every AI decision โ verifiable offline. Evidence, not a compliance guarantee.
|
|
5
|
+
Author: Raghav Mishra
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: powerailabs-core<0.2,>=0.1
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# powerailabs-acttrace
|
|
12
|
+
|
|
13
|
+
A tamper-evident, append-only record of every AI decision โ what model, what context, what it
|
|
14
|
+
cost, which tools, and who signed off โ mapped to control templates and exportable as an evidence
|
|
15
|
+
pack. No database, no infra: integrity comes from a hash chain, not a server.
|
|
16
|
+
|
|
17
|
+
**Audit-ready evidence in 5 lines โ and verifiable offline.**
|
|
18
|
+
|
|
19
|
+
 
|
|
20
|
+
|
|
21
|
+
๐ง building (v0) ยท `pip install powerailabs-acttrace` ยท `from powerailabs.acttrace import AuditLog`
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from powerailabs.core import instrument
|
|
25
|
+
from powerailabs.acttrace import AuditLog
|
|
26
|
+
|
|
27
|
+
client = instrument(OpenAI())
|
|
28
|
+
audit = AuditLog(system="loan_triage", risk_tier="high") # auto-subscribes to core events
|
|
29
|
+
|
|
30
|
+
with audit.decision(input=application, actor="agent") as d:
|
|
31
|
+
resp = client.chat.completions.create(model="gpt-4o", messages=msgs) # auto-logged
|
|
32
|
+
d.record(model="gpt-4o", prompt_id="triage@v3") # cost/context captured for free
|
|
33
|
+
d.human_oversight(reviewer="ops@bank", action="approved")
|
|
34
|
+
|
|
35
|
+
audit.export("evidence_q3.jsonl", framework="eu_ai_act") # evidence pack
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
acttrace verify evidence_q3.jsonl # re-walks the hash chain; exits non-zero if broken
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Wrap-around, auto-subscribing.** Because cost (`tokenguard`) and context decisions (`contextkit`)
|
|
43
|
+
ride core's shared event stream, the log fills itself โ install the stack, call `instrument()`
|
|
44
|
+
once, and the record already knows the model, the context, the cost, and the tools. Each entry is
|
|
45
|
+
chained (`hash = sha256(prev + canonical(entry))`), so any later edit breaks the chain.
|
|
46
|
+
|
|
47
|
+
> `acttrace` produces **evidence to support** compliance โ it is **not** legal advice and not a
|
|
48
|
+
> compliance guarantee. Control mappings are a starting template for your compliance team.
|
|
49
|
+
|
|
50
|
+
See [`docs/acttrace.md`](../../docs/acttrace.md). *Part of the PowerAI Labs stack โ github.com/PowerAI-Labs/powerailabs.*
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# powerailabs-acttrace
|
|
2
|
+
|
|
3
|
+
A tamper-evident, append-only record of every AI decision โ what model, what context, what it
|
|
4
|
+
cost, which tools, and who signed off โ mapped to control templates and exportable as an evidence
|
|
5
|
+
pack. No database, no infra: integrity comes from a hash chain, not a server.
|
|
6
|
+
|
|
7
|
+
**Audit-ready evidence in 5 lines โ and verifiable offline.**
|
|
8
|
+
|
|
9
|
+
 
|
|
10
|
+
|
|
11
|
+
๐ง building (v0) ยท `pip install powerailabs-acttrace` ยท `from powerailabs.acttrace import AuditLog`
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from powerailabs.core import instrument
|
|
15
|
+
from powerailabs.acttrace import AuditLog
|
|
16
|
+
|
|
17
|
+
client = instrument(OpenAI())
|
|
18
|
+
audit = AuditLog(system="loan_triage", risk_tier="high") # auto-subscribes to core events
|
|
19
|
+
|
|
20
|
+
with audit.decision(input=application, actor="agent") as d:
|
|
21
|
+
resp = client.chat.completions.create(model="gpt-4o", messages=msgs) # auto-logged
|
|
22
|
+
d.record(model="gpt-4o", prompt_id="triage@v3") # cost/context captured for free
|
|
23
|
+
d.human_oversight(reviewer="ops@bank", action="approved")
|
|
24
|
+
|
|
25
|
+
audit.export("evidence_q3.jsonl", framework="eu_ai_act") # evidence pack
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
acttrace verify evidence_q3.jsonl # re-walks the hash chain; exits non-zero if broken
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Wrap-around, auto-subscribing.** Because cost (`tokenguard`) and context decisions (`contextkit`)
|
|
33
|
+
ride core's shared event stream, the log fills itself โ install the stack, call `instrument()`
|
|
34
|
+
once, and the record already knows the model, the context, the cost, and the tools. Each entry is
|
|
35
|
+
chained (`hash = sha256(prev + canonical(entry))`), so any later edit breaks the chain.
|
|
36
|
+
|
|
37
|
+
> `acttrace` produces **evidence to support** compliance โ it is **not** legal advice and not a
|
|
38
|
+
> compliance guarantee. Control mappings are a starting template for your compliance team.
|
|
39
|
+
|
|
40
|
+
See [`docs/acttrace.md`](../../docs/acttrace.md). *Part of the PowerAI Labs stack โ github.com/PowerAI-Labs/powerailabs.*
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "powerailabs-acttrace"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Audit: a tamper-evident, hash-chained, auto-populated record of every AI decision โ verifiable offline. Evidence, not a compliance guarantee."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Raghav Mishra" }]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
dependencies = ["powerailabs-core>=0.1,<0.2"]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
acttrace = "powerailabs.acttrace.cli:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/powerailabs"] # contributes powerailabs/acttrace only โ NEVER add src/powerailabs/__init__.py
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""powerailabs.acttrace โ a tamper-evident, auto-populated audit log for AI decisions.
|
|
2
|
+
|
|
3
|
+
Construct an :class:`AuditLog` and it **subscribes** to ``powerailabs.core``'s event stream: every
|
|
4
|
+
instrumented model/tool call โ and the context decisions ``contextkit`` and cost ``tokenguard``
|
|
5
|
+
ride on that same stream โ becomes an audit entry with no per-call wiring. You add only the
|
|
6
|
+
explicit human-facing events (``decision``, ``human_oversight``).
|
|
7
|
+
|
|
8
|
+
Integrity comes from a **hash chain**, not a server: ``entry.hash = sha256(prev_hash +
|
|
9
|
+
canonical(entry))``, so editing any past entry breaks every entry after it. ``acttrace verify
|
|
10
|
+
file.jsonl`` re-walks the chain offline.
|
|
11
|
+
|
|
12
|
+
> This produces **evidence to support** compliance (e.g. EU AI Act record-keeping / human
|
|
13
|
+
> oversight). It is **not** legal advice and not a compliance guarantee. Control mappings are a
|
|
14
|
+
> starting template for your compliance team to adjust.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from contextvars import ContextVar
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from powerailabs.core import bus
|
|
31
|
+
from powerailabs.core.types import LLMCall, ToolCall
|
|
32
|
+
|
|
33
|
+
__all__ = ["AuditLog", "AuditEntry", "verify", "GENESIS"]
|
|
34
|
+
|
|
35
|
+
GENESIS = "0" * 64
|
|
36
|
+
|
|
37
|
+
_active_decision: ContextVar[str | None] = ContextVar("powerailabs_acttrace_decision", default=None)
|
|
38
|
+
|
|
39
|
+
# Starting-template control mapping (NOT legal advice). docs/acttrace.md ยง5, ยง7.
|
|
40
|
+
_CONTROLS: dict[str, dict[str, list[str]]] = {
|
|
41
|
+
"eu_ai_act": {
|
|
42
|
+
"decision": ["Art.12 record-keeping"],
|
|
43
|
+
"llm_call": ["Art.12 logging"],
|
|
44
|
+
"tool_call": ["Art.12 logging"],
|
|
45
|
+
"context_assembly": ["Art.12 logging"],
|
|
46
|
+
"human_oversight": ["Art.14 human oversight"],
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AuditEntry:
|
|
53
|
+
"""One link in the hash chain. docs/acttrace.md ยง5."""
|
|
54
|
+
|
|
55
|
+
seq: int
|
|
56
|
+
ts: str
|
|
57
|
+
type: str # decision | llm_call | tool_call | human_oversight | context_assembly | ...
|
|
58
|
+
payload: dict
|
|
59
|
+
prev_hash: str
|
|
60
|
+
hash: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _jsonable(obj: Any) -> Any:
|
|
64
|
+
if obj is None or isinstance(obj, (bool, int, float, str)):
|
|
65
|
+
return obj
|
|
66
|
+
if isinstance(obj, datetime):
|
|
67
|
+
return obj.isoformat()
|
|
68
|
+
if isinstance(obj, dict):
|
|
69
|
+
return {str(k): _jsonable(v) for k, v in obj.items()}
|
|
70
|
+
if isinstance(obj, (list, tuple)):
|
|
71
|
+
return [_jsonable(v) for v in obj]
|
|
72
|
+
if hasattr(obj, "amount") and hasattr(obj, "currency"): # Money
|
|
73
|
+
return f"{obj.amount} {obj.currency}"
|
|
74
|
+
if hasattr(obj, "__dict__"):
|
|
75
|
+
return _jsonable(vars(obj))
|
|
76
|
+
return str(obj)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _canonical(payload: dict) -> str:
|
|
80
|
+
return json.dumps(_jsonable(payload), sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _chain_hash(prev_hash: str, seq: int, ts: str, etype: str, payload: dict) -> str:
|
|
84
|
+
body = _canonical({"seq": seq, "ts": ts, "type": etype, "payload": payload})
|
|
85
|
+
return hashlib.sha256((prev_hash + body).encode("utf-8")).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AuditLog:
|
|
89
|
+
"""A hash-chained, append-only, auto-populating audit log. docs/acttrace.md ยง3, ยง5."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, system: str, risk_tier: str = "limited", path: str | None = None) -> None:
|
|
92
|
+
self.system = system
|
|
93
|
+
self.risk_tier = risk_tier
|
|
94
|
+
self.path = Path(path) if path else None
|
|
95
|
+
self.entries: list[AuditEntry] = []
|
|
96
|
+
self._head = GENESIS
|
|
97
|
+
if self.path is not None:
|
|
98
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
self.path.write_text("", encoding="utf-8")
|
|
100
|
+
self._append("audit_open", {"system": system, "risk_tier": risk_tier})
|
|
101
|
+
bus.subscribe(self._on_event)
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ chain
|
|
104
|
+
|
|
105
|
+
def _append(self, etype: str, payload: dict) -> AuditEntry:
|
|
106
|
+
seq = len(self.entries)
|
|
107
|
+
ts = datetime.now(UTC).isoformat()
|
|
108
|
+
h = _chain_hash(self._head, seq, ts, etype, payload)
|
|
109
|
+
entry = AuditEntry(seq, ts, etype, _jsonable(payload), self._head, h)
|
|
110
|
+
self.entries.append(entry)
|
|
111
|
+
self._head = h
|
|
112
|
+
if self.path is not None:
|
|
113
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
114
|
+
fh.write(json.dumps(entry.__dict__, ensure_ascii=False) + "\n")
|
|
115
|
+
return entry
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ auto-capture
|
|
118
|
+
|
|
119
|
+
def _on_event(self, event: Any) -> None:
|
|
120
|
+
did = _active_decision.get()
|
|
121
|
+
if isinstance(event, LLMCall):
|
|
122
|
+
self._append(
|
|
123
|
+
"llm_call",
|
|
124
|
+
{
|
|
125
|
+
"decision_id": did,
|
|
126
|
+
"provider": event.provider,
|
|
127
|
+
"model": event.model,
|
|
128
|
+
"usage": _jsonable(event.usage),
|
|
129
|
+
"cost": _jsonable(event.cost),
|
|
130
|
+
"latency_ms": event.latency_ms,
|
|
131
|
+
"replayed": event.metadata.get("replayed", False),
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
elif isinstance(event, ToolCall):
|
|
135
|
+
self._append(
|
|
136
|
+
"tool_call",
|
|
137
|
+
{"decision_id": did, "name": event.name, "arguments": _jsonable(event.arguments)},
|
|
138
|
+
)
|
|
139
|
+
elif hasattr(event, "decisions") and hasattr(event, "budget"): # contextkit AssemblyReport
|
|
140
|
+
self._append(
|
|
141
|
+
"context_assembly",
|
|
142
|
+
{
|
|
143
|
+
"decision_id": did,
|
|
144
|
+
"model": getattr(event, "model", None),
|
|
145
|
+
"budget": event.budget,
|
|
146
|
+
"used": getattr(event, "used", None),
|
|
147
|
+
"decisions": _jsonable(event.decisions),
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def detach(self) -> None:
|
|
152
|
+
"""Stop subscribing to the core event stream."""
|
|
153
|
+
if self._on_event in bus._subscribers:
|
|
154
|
+
bus._subscribers.remove(self._on_event)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------ explicit events
|
|
157
|
+
|
|
158
|
+
@contextmanager
|
|
159
|
+
def decision(self, input: Any = None, actor: str = "agent") -> Iterator[Decision]:
|
|
160
|
+
"""Group a unit of work. Auto-captured calls inside it are tagged with this decision."""
|
|
161
|
+
did = uuid.uuid4().hex
|
|
162
|
+
self._append("decision", {"decision_id": did, "input": _jsonable(input), "actor": actor})
|
|
163
|
+
token = _active_decision.set(did)
|
|
164
|
+
try:
|
|
165
|
+
yield Decision(self, did)
|
|
166
|
+
finally:
|
|
167
|
+
_active_decision.reset(token)
|
|
168
|
+
self._append("decision_end", {"decision_id": did})
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------ export
|
|
171
|
+
|
|
172
|
+
def export(self, path: str, framework: str | None = None) -> None:
|
|
173
|
+
"""Write the chain as a JSONL evidence pack, optionally annotated with control IDs."""
|
|
174
|
+
controls = _CONTROLS.get(framework or "", {})
|
|
175
|
+
out = Path(path)
|
|
176
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
with out.open("w", encoding="utf-8") as fh:
|
|
178
|
+
meta = {
|
|
179
|
+
"_meta": {
|
|
180
|
+
"system": self.system,
|
|
181
|
+
"risk_tier": self.risk_tier,
|
|
182
|
+
"framework": framework,
|
|
183
|
+
"head_hash": self._head,
|
|
184
|
+
"entries": len(self.entries),
|
|
185
|
+
"disclaimer": "Evidence to support compliance โ not legal advice.",
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
fh.write(json.dumps(meta, ensure_ascii=False) + "\n")
|
|
189
|
+
for entry in self.entries:
|
|
190
|
+
row = dict(entry.__dict__)
|
|
191
|
+
if framework:
|
|
192
|
+
row["controls"] = controls.get(entry.type, [])
|
|
193
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class Decision:
|
|
198
|
+
"""Handle for the active decision span (yielded by :meth:`AuditLog.decision`)."""
|
|
199
|
+
|
|
200
|
+
log: AuditLog
|
|
201
|
+
id: str
|
|
202
|
+
|
|
203
|
+
def record(self, **fields: Any) -> None:
|
|
204
|
+
"""Record decision metadata (e.g. ``model``, ``prompt_id``)."""
|
|
205
|
+
self.log._append("decision_record", {"decision_id": self.id, **fields})
|
|
206
|
+
|
|
207
|
+
def human_oversight(self, reviewer: str, action: str, note: str = "") -> None:
|
|
208
|
+
"""Record an Art. 14-style human-oversight event: who reviewed, what action, when."""
|
|
209
|
+
self.log._append(
|
|
210
|
+
"human_oversight",
|
|
211
|
+
{"decision_id": self.id, "reviewer": reviewer, "action": action, "note": note},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def verify(path: str) -> tuple[bool, str]:
|
|
216
|
+
"""Re-walk the hash chain in a JSONL file. Returns ``(ok, detail)``. docs/acttrace.md ยง5."""
|
|
217
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
218
|
+
prev = GENESIS
|
|
219
|
+
seen = 0
|
|
220
|
+
for line in lines:
|
|
221
|
+
line = line.strip()
|
|
222
|
+
if not line:
|
|
223
|
+
continue
|
|
224
|
+
row = json.loads(line)
|
|
225
|
+
if "_meta" in row: # export header, not a chain entry
|
|
226
|
+
continue
|
|
227
|
+
expected = _chain_hash(prev, row["seq"], row["ts"], row["type"], row["payload"])
|
|
228
|
+
if row["prev_hash"] != prev:
|
|
229
|
+
return False, f"broken link at seq {row['seq']}: prev_hash mismatch"
|
|
230
|
+
if row["hash"] != expected:
|
|
231
|
+
return False, f"tampered entry at seq {row['seq']}: hash mismatch"
|
|
232
|
+
prev = row["hash"]
|
|
233
|
+
seen += 1
|
|
234
|
+
return True, f"ok: {seen} entries, head {prev[:12]}โฆ"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""``acttrace`` CLI: an offline verifier for the hash chain. docs/acttrace.md ยง3.
|
|
2
|
+
|
|
3
|
+
acttrace verify evidence_q3.jsonl # exits non-zero if the chain is broken
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from . import verify
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
16
|
+
"""Entry point for the ``acttrace`` console script. Returns a process exit code."""
|
|
17
|
+
parser = argparse.ArgumentParser(prog="acttrace", description="Audit-log tools.")
|
|
18
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
verify_cmd = sub.add_parser("verify", help="re-walk a JSONL log's hash chain")
|
|
20
|
+
verify_cmd.add_argument("path", help="path to the .jsonl audit/evidence file")
|
|
21
|
+
|
|
22
|
+
args = parser.parse_args(argv)
|
|
23
|
+
if args.command == "verify":
|
|
24
|
+
ok, detail = verify(args.path)
|
|
25
|
+
print(detail)
|
|
26
|
+
return 0 if ok else 1
|
|
27
|
+
return 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__": # pragma: no cover
|
|
31
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Auto-populated, hash-chained, tamper-evident audit log. Offline; mock clients only."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from powerailabs.acttrace import AuditLog, verify
|
|
8
|
+
from powerailabs.core import bus, instrument
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def _clean_bus():
|
|
13
|
+
bus._reset()
|
|
14
|
+
yield
|
|
15
|
+
bus._reset()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _client():
|
|
19
|
+
class Completions:
|
|
20
|
+
def create(self, **kwargs):
|
|
21
|
+
return SimpleNamespace(usage=SimpleNamespace(prompt_tokens=100, completion_tokens=50))
|
|
22
|
+
|
|
23
|
+
return instrument(SimpleNamespace(chat=SimpleNamespace(completions=Completions())))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_auto_populates_from_instrumented_calls(tmp_path):
|
|
27
|
+
log = AuditLog(system="loan_triage", risk_tier="high", path=str(tmp_path / "audit.jsonl"))
|
|
28
|
+
try:
|
|
29
|
+
client = _client()
|
|
30
|
+
with log.decision(input={"amount": 5000}, actor="agent") as d:
|
|
31
|
+
client.chat.completions.create(
|
|
32
|
+
model="gpt-4o", messages=[{"role": "user", "content": "x"}]
|
|
33
|
+
)
|
|
34
|
+
d.record(model="gpt-4o", prompt_id="triage@v3")
|
|
35
|
+
d.human_oversight(reviewer="ops@bank", action="approved", note="manual check")
|
|
36
|
+
finally:
|
|
37
|
+
log.detach()
|
|
38
|
+
|
|
39
|
+
types = [e.type for e in log.entries]
|
|
40
|
+
assert "decision" in types
|
|
41
|
+
assert "llm_call" in types # captured with zero per-call wiring
|
|
42
|
+
assert "human_oversight" in types
|
|
43
|
+
# the llm_call carries cost + is tagged with the active decision
|
|
44
|
+
llm = next(e for e in log.entries if e.type == "llm_call")
|
|
45
|
+
assert llm.payload["cost"] is not None
|
|
46
|
+
assert llm.payload["decision_id"] is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_chain_verifies_and_detects_tampering(tmp_path):
|
|
50
|
+
path = tmp_path / "audit.jsonl"
|
|
51
|
+
log = AuditLog(system="s", path=str(path))
|
|
52
|
+
try:
|
|
53
|
+
with log.decision(input="app") as d:
|
|
54
|
+
d.human_oversight(reviewer="r", action="approved")
|
|
55
|
+
finally:
|
|
56
|
+
log.detach()
|
|
57
|
+
|
|
58
|
+
ok, detail = verify(str(path))
|
|
59
|
+
assert ok, detail
|
|
60
|
+
|
|
61
|
+
# Tamper: edit a payload in the middle of the chain.
|
|
62
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
63
|
+
row = json.loads(lines[1])
|
|
64
|
+
row["payload"]["actor"] = "HACKED"
|
|
65
|
+
lines[1] = json.dumps(row)
|
|
66
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
67
|
+
|
|
68
|
+
ok, detail = verify(str(path))
|
|
69
|
+
assert not ok and "tampered" in detail
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_context_assembly_auto_captured(tmp_path):
|
|
73
|
+
# contextkit emits an AssemblyReport on the bus; acttrace records it without importing it.
|
|
74
|
+
from powerailabs.contextkit import Block, Context
|
|
75
|
+
|
|
76
|
+
log = AuditLog(system="s", path=str(tmp_path / "a.jsonl"))
|
|
77
|
+
try:
|
|
78
|
+
with log.decision(input="q"):
|
|
79
|
+
ctx = Context(budget_tokens=1000, model="gpt-4o")
|
|
80
|
+
ctx.add(Block("system", priority=10, role="system"))
|
|
81
|
+
ctx.assemble()
|
|
82
|
+
finally:
|
|
83
|
+
log.detach()
|
|
84
|
+
assert any(e.type == "context_assembly" for e in log.entries)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_export_with_control_mapping(tmp_path):
|
|
88
|
+
log = AuditLog(system="s", risk_tier="high")
|
|
89
|
+
try:
|
|
90
|
+
with log.decision(input="x") as d:
|
|
91
|
+
d.human_oversight(reviewer="r", action="approved")
|
|
92
|
+
finally:
|
|
93
|
+
log.detach()
|
|
94
|
+
out = tmp_path / "evidence.jsonl"
|
|
95
|
+
log.export(str(out), framework="eu_ai_act")
|
|
96
|
+
|
|
97
|
+
rows = [json.loads(ln) for ln in out.read_text(encoding="utf-8").splitlines()]
|
|
98
|
+
assert "_meta" in rows[0]
|
|
99
|
+
assert "not legal advice" in rows[0]["_meta"]["disclaimer"].lower()
|
|
100
|
+
oversight = next(r for r in rows if r.get("type") == "human_oversight")
|
|
101
|
+
assert oversight["controls"] == ["Art.14 human oversight"]
|
|
102
|
+
# exported evidence still verifies
|
|
103
|
+
ok, _ = verify(str(out))
|
|
104
|
+
assert ok
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_cli_verify(tmp_path, capsys):
|
|
108
|
+
from powerailabs.acttrace.cli import main
|
|
109
|
+
|
|
110
|
+
path = tmp_path / "audit.jsonl"
|
|
111
|
+
log = AuditLog(system="s", path=str(path))
|
|
112
|
+
log.detach()
|
|
113
|
+
assert main(["verify", str(path)]) == 0
|
|
114
|
+
|
|
115
|
+
path.write_text(
|
|
116
|
+
path.read_text(encoding="utf-8").replace('"system"', '"SYSTEM"'), encoding="utf-8"
|
|
117
|
+
)
|
|
118
|
+
assert main(["verify", str(path)]) == 1
|