@cendor/acttrace 0.2.0
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.
- package/LICENSE +201 -0
- package/README.md +79 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/detectors.d.ts +69 -0
- package/dist/detectors.d.ts.map +1 -0
- package/dist/detectors.js +378 -0
- package/dist/detectors.js.map +1 -0
- package/dist/guard.d.ts +36 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/guard.js +124 -0
- package/dist/guard.js.map +1 -0
- package/dist/hash.d.ts +7 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +32 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +145 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +741 -0
- package/dist/index.js.map +1 -0
- package/dist/ner.d.ts +16 -0
- package/dist/ner.d.ts.map +1 -0
- package/dist/ner.js +21 -0
- package/dist/ner.js.map +1 -0
- package/dist/packs.d.ts +31 -0
- package/dist/packs.d.ts.map +1 -0
- package/dist/packs.js +113 -0
- package/dist/packs.js.map +1 -0
- package/dist/policy.d.ts +58 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +119 -0
- package/dist/policy.js.map +1 -0
- package/dist/pyjson.d.ts +33 -0
- package/dist/pyjson.d.ts.map +1 -0
- package/dist/pyjson.js +250 -0
- package/dist/pyjson.js.map +1 -0
- package/dist/storage.d.ts +17 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +71 -0
- package/dist/storage.js.map +1 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@cendor/acttrace` — a tamper-evident, auto-populated audit log for AI decisions. The TS port of
|
|
3
|
+
* `cendor.acttrace` (regex/pattern detectors only; no Presidio).
|
|
4
|
+
*
|
|
5
|
+
* Construct an {@link AuditLog} and it **subscribes** to `@cendor/core`'s event stream: every
|
|
6
|
+
* instrumented model/tool call — and the context decisions `@cendor/contextkit` rides on the same
|
|
7
|
+
* stream — becomes an audit entry with no per-call wiring. You add only the explicit human-facing
|
|
8
|
+
* events (`decision`, `humanOversight`).
|
|
9
|
+
*
|
|
10
|
+
* Integrity comes from a **hash chain**, not a server: `entry.hash = sha256(prev_hash +
|
|
11
|
+
* canonical(entry))`, so editing any past entry breaks every entry after it. {@link verify} re-walks
|
|
12
|
+
* the chain offline. Byte-conformant with the Python implementation: the canonical bytes that are
|
|
13
|
+
* hashed (and the HMAC inputs) are identical across languages.
|
|
14
|
+
*/
|
|
15
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
|
+
import { LLMCall, ToolCall, Usage, bus } from '@cendor/core';
|
|
17
|
+
import { DETECTORS, detectors, groupOf, registerDetector, scanCounts, scrub, } from './detectors.js';
|
|
18
|
+
import { PolicyViolation, guard } from './guard.js';
|
|
19
|
+
import { hmacSha256Hex, sha256Hex, timingSafeEqualHex } from './hash.js';
|
|
20
|
+
import { nerAvailable, nerRedactor } from './ner.js';
|
|
21
|
+
import { LOCALE_PACKS, enableEntropyDetector, enableLocalePack } from './packs.js';
|
|
22
|
+
import { Finding, Policy, redact, scan } from './policy.js';
|
|
23
|
+
import { PyFloat, canonical, dumpsDefault, parsePreserving } from './pyjson.js';
|
|
24
|
+
import { fsChainStorage, fsReadLines, memoryChainStorage } from './storage.js';
|
|
25
|
+
export { DETECTORS, registerDetector, detectors, Policy, Finding, scan, redact, guard, PolicyViolation, enableLocalePack, enableEntropyDetector, LOCALE_PACKS, nerAvailable, nerRedactor, };
|
|
26
|
+
/** The `prev_hash` of the first entry: 64 ASCII zeros. */
|
|
27
|
+
export const GENESIS = '0'.repeat(64);
|
|
28
|
+
/** Named error mirroring Python's `ValueError` (message substring is the contract). */
|
|
29
|
+
class ValueError extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'ValueError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Warned when `AuditLog({ maxEntries })` is set without `path`. Bounding the in-memory ring relies on
|
|
37
|
+
* the file as the source of truth; without a path, evicted entries are lost entirely.
|
|
38
|
+
*/
|
|
39
|
+
export class BoundedMemoryWithoutPathWarning extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'BoundedMemoryWithoutPathWarning';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// acttrace's own decision-correlation context (a 32-hex id) — independent of core's trace id.
|
|
46
|
+
const activeDecision = new AsyncLocalStorage();
|
|
47
|
+
function currentDecision() {
|
|
48
|
+
return activeDecision.getStore() ?? null;
|
|
49
|
+
}
|
|
50
|
+
// --------------------------------------------------------------------------- framework tables
|
|
51
|
+
/** event type -> framework control IDs (starting templates, NOT legal advice). */
|
|
52
|
+
const CONTROLS = {
|
|
53
|
+
eu_ai_act: {
|
|
54
|
+
audit_open: ['Art.12 record-keeping', 'Art.19 automatically generated logs'],
|
|
55
|
+
decision: ['Art.12 record-keeping', 'Art.13 transparency'],
|
|
56
|
+
decision_record: ['Art.12 record-keeping', 'Art.13 transparency'],
|
|
57
|
+
decision_end: ['Art.12 record-keeping'],
|
|
58
|
+
llm_call: [
|
|
59
|
+
'Art.12 logging',
|
|
60
|
+
'Art.19 automatically generated logs',
|
|
61
|
+
'Art.72 post-market monitoring',
|
|
62
|
+
],
|
|
63
|
+
tool_call: ['Art.12 logging', 'Art.19 automatically generated logs'],
|
|
64
|
+
context_assembly: ['Art.12 logging', 'Art.13 transparency'],
|
|
65
|
+
human_oversight: ['Art.14 human oversight', 'Art.26(5) deployer oversight'],
|
|
66
|
+
policy_flag: ['Art.10 data governance', 'Art.12 record-keeping'],
|
|
67
|
+
},
|
|
68
|
+
nist_rmf: {
|
|
69
|
+
audit_open: ['GOVERN-1.1'],
|
|
70
|
+
decision: ['MAP-1.1', 'MEASURE-2.1'],
|
|
71
|
+
decision_record: ['MEASURE-2.1'],
|
|
72
|
+
decision_end: ['MEASURE-2.1'],
|
|
73
|
+
llm_call: ['MEASURE-2.1'],
|
|
74
|
+
tool_call: ['MEASURE-2.1'],
|
|
75
|
+
context_assembly: ['MEASURE-2.1'],
|
|
76
|
+
human_oversight: ['MANAGE-2.1'],
|
|
77
|
+
policy_flag: ['MANAGE-2.1', 'MEASURE-2.1'],
|
|
78
|
+
},
|
|
79
|
+
iso_42001: {
|
|
80
|
+
audit_open: ['A.6.2.8 event logs'],
|
|
81
|
+
decision: ['A.6.2.8 event logs', 'A.5.2 AI system impact assessment'],
|
|
82
|
+
decision_record: ['A.6.2.8 event logs'],
|
|
83
|
+
decision_end: ['A.6.2.8 event logs'],
|
|
84
|
+
llm_call: [
|
|
85
|
+
'A.6.2.8 event logs',
|
|
86
|
+
'A.6.2.6 operation & monitoring',
|
|
87
|
+
'Cl.9.1 monitoring & measurement',
|
|
88
|
+
],
|
|
89
|
+
tool_call: ['A.6.2.8 event logs', 'A.6.2.6 operation & monitoring'],
|
|
90
|
+
context_assembly: ['A.6.2.8 event logs', 'A.6.2.6 operation & monitoring'],
|
|
91
|
+
human_oversight: ['A.9.2 responsible use', 'A.9.4 intended use'],
|
|
92
|
+
policy_flag: ['A.7 data for AI systems', 'A.6.2.8 event logs', 'A.9.2 responsible use'],
|
|
93
|
+
},
|
|
94
|
+
gdpr: {
|
|
95
|
+
audit_open: ['Art.30 records of processing', 'Art.5(2) accountability'],
|
|
96
|
+
decision: ['Art.22 automated decision-making', 'Art.5(2) accountability'],
|
|
97
|
+
decision_record: ['Art.22 automated decision-making'],
|
|
98
|
+
decision_end: ['Art.30 records of processing'],
|
|
99
|
+
llm_call: ['Art.30 records of processing'],
|
|
100
|
+
tool_call: ['Art.30 records of processing'],
|
|
101
|
+
context_assembly: ['Art.30 records of processing'],
|
|
102
|
+
human_oversight: ['Art.22(3) right to human intervention'],
|
|
103
|
+
policy_flag: [
|
|
104
|
+
'Art.9 special-category data',
|
|
105
|
+
'Art.5(1)(c) data minimisation',
|
|
106
|
+
'Art.30 records of processing',
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
/** Category/group-specific control pointers layered on top of the per-type `policy_flag` mapping. */
|
|
111
|
+
const CATEGORY_CONTROLS = {
|
|
112
|
+
special_category: {
|
|
113
|
+
gdpr: ['Art.9 special-category data'],
|
|
114
|
+
eu_ai_act: ['Art.10(5) special categories for bias detection'],
|
|
115
|
+
},
|
|
116
|
+
gov_id: {
|
|
117
|
+
gdpr: ['Art.87 processing of national identification numbers'],
|
|
118
|
+
},
|
|
119
|
+
financial: {
|
|
120
|
+
gdpr: ['PCI-DSS 3.3/3.4 (payment-card data — cross-reference)'],
|
|
121
|
+
eu_ai_act: ['PCI-DSS 3.3/3.4 (payment-card data — cross-reference)'],
|
|
122
|
+
},
|
|
123
|
+
pii: {
|
|
124
|
+
gdpr: ['Art.4(1) personal data', 'Art.5(1)(c) data minimisation'],
|
|
125
|
+
},
|
|
126
|
+
secret: {
|
|
127
|
+
gdpr: ['Art.32 security of processing'],
|
|
128
|
+
},
|
|
129
|
+
credential: {
|
|
130
|
+
gdpr: ['Art.32 security of processing'],
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
/** Frameworks with a bundled (starting-template) control mapping for {@link AuditLog.export}. */
|
|
134
|
+
export function frameworks() {
|
|
135
|
+
return Object.keys(CONTROLS).sort();
|
|
136
|
+
}
|
|
137
|
+
function controlsForEntry(entry, framework, controls) {
|
|
138
|
+
const result = [...(controls[entry.type] ?? [])];
|
|
139
|
+
if (entry.type !== 'policy_flag')
|
|
140
|
+
return result;
|
|
141
|
+
const payload = entry.payload;
|
|
142
|
+
const data = payload && typeof payload === 'object' ? payload.data : undefined;
|
|
143
|
+
const cats = Array.isArray(data) ? data : typeof data === 'string' ? [data] : [];
|
|
144
|
+
for (const cat of cats) {
|
|
145
|
+
const keys = [
|
|
146
|
+
typeof cat === 'string' ? cat : null,
|
|
147
|
+
typeof cat === 'string' ? groupOf(cat) : null,
|
|
148
|
+
];
|
|
149
|
+
for (const key of keys) {
|
|
150
|
+
if (key === null)
|
|
151
|
+
continue;
|
|
152
|
+
for (const control of CATEGORY_CONTROLS[key]?.[framework] ?? []) {
|
|
153
|
+
if (!result.includes(control))
|
|
154
|
+
result.push(control);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
// --------------------------------------------------------------------------- canonicalization
|
|
161
|
+
/** Map a core {@link Usage} to the snake_case wire dict, in Python field order (all ints). */
|
|
162
|
+
function usageJsonable(u) {
|
|
163
|
+
return {
|
|
164
|
+
input_tokens: u.inputTokens,
|
|
165
|
+
output_tokens: u.outputTokens,
|
|
166
|
+
cached_tokens: u.cachedTokens,
|
|
167
|
+
reasoning_tokens: u.reasoningTokens,
|
|
168
|
+
cache_write: u.cacheWrite,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function isPlainObject(o) {
|
|
172
|
+
if (o === null || typeof o !== 'object')
|
|
173
|
+
return false;
|
|
174
|
+
const proto = Object.getPrototypeOf(o);
|
|
175
|
+
return proto === Object.prototype || proto === null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Normalize an arbitrary value to a JSON-plain {@link PyValue} — mirroring Python `_jsonable`'s exact
|
|
179
|
+
* branch order (bool/int/float/str, datetime, dict, list/tuple, Money duck-type before generic
|
|
180
|
+
* `__dict__`, `vars()` fields only). Preserves int vs float (bigint/{@link PyFloat}).
|
|
181
|
+
*/
|
|
182
|
+
function jsonable(obj) {
|
|
183
|
+
if (obj === null || obj === undefined)
|
|
184
|
+
return null;
|
|
185
|
+
const t = typeof obj;
|
|
186
|
+
if (t === 'boolean')
|
|
187
|
+
return obj;
|
|
188
|
+
if (t === 'bigint')
|
|
189
|
+
return obj;
|
|
190
|
+
if (obj instanceof PyFloat)
|
|
191
|
+
return obj;
|
|
192
|
+
if (t === 'number')
|
|
193
|
+
return obj;
|
|
194
|
+
if (t === 'string')
|
|
195
|
+
return obj;
|
|
196
|
+
if (obj instanceof Date)
|
|
197
|
+
return obj.toISOString();
|
|
198
|
+
if (Array.isArray(obj))
|
|
199
|
+
return obj.map(jsonable);
|
|
200
|
+
if (obj instanceof Usage)
|
|
201
|
+
return usageJsonable(obj);
|
|
202
|
+
if (t === 'object') {
|
|
203
|
+
// Plain dict: recurse, stringifying keys (Python `{str(k): _jsonable(v)}`).
|
|
204
|
+
if (isPlainObject(obj)) {
|
|
205
|
+
const out = {};
|
|
206
|
+
for (const [k, v] of Object.entries(obj))
|
|
207
|
+
out[String(k)] = jsonable(v);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
const o = obj;
|
|
211
|
+
// Money duck-type (checked before the generic `__dict__` branch).
|
|
212
|
+
if ('amount' in o && 'currency' in o)
|
|
213
|
+
return `${String(o.amount)} ${String(o.currency)}`;
|
|
214
|
+
// Any other object with fields -> recurse over its own enumerable attributes (like `vars()`).
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const [k, v] of Object.entries(o))
|
|
217
|
+
out[String(k)] = jsonable(v);
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
return String(obj);
|
|
221
|
+
}
|
|
222
|
+
/** `sha256(prev_hash + canonical({payload, seq, ts, type}))` — prev_hash TEXT-prepended, 4 keys. */
|
|
223
|
+
export function chainHash(prevHash, seq, ts, etype, payload) {
|
|
224
|
+
const body = canonical({ seq, ts, type: etype, payload });
|
|
225
|
+
return sha256Hex(prevHash + body);
|
|
226
|
+
}
|
|
227
|
+
/** HMAC over an export `_meta` header's four completeness fields (entries, head_hash, risk_tier, system). */
|
|
228
|
+
export function metaSignature(key, meta) {
|
|
229
|
+
const body = canonical({
|
|
230
|
+
system: meta.system ?? null,
|
|
231
|
+
risk_tier: meta.risk_tier ?? null,
|
|
232
|
+
head_hash: meta.head_hash ?? null,
|
|
233
|
+
entries: meta.entries ?? null,
|
|
234
|
+
});
|
|
235
|
+
return hmacSha256Hex(key, body);
|
|
236
|
+
}
|
|
237
|
+
// --------------------------------------------------------------------------- auto-flag machinery
|
|
238
|
+
/** Types that carry caller content; a detection in one is worth a follow-up flag. */
|
|
239
|
+
const AUTO_REDACT_TYPES = new Set([
|
|
240
|
+
'decision',
|
|
241
|
+
'decision_record',
|
|
242
|
+
'llm_call',
|
|
243
|
+
'tool_call',
|
|
244
|
+
'context_assembly',
|
|
245
|
+
]);
|
|
246
|
+
const ACTION_VERB = {
|
|
247
|
+
block: 'blocked',
|
|
248
|
+
redact: 'redacted',
|
|
249
|
+
flag: 'flagged',
|
|
250
|
+
};
|
|
251
|
+
const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
|
|
252
|
+
function maxSeverity(severities) {
|
|
253
|
+
let best = 'warning';
|
|
254
|
+
let bestRank = -1;
|
|
255
|
+
for (const s of severities) {
|
|
256
|
+
const rank = SEVERITY_RANK[s] ?? 0;
|
|
257
|
+
if (rank > bestRank) {
|
|
258
|
+
bestRank = rank;
|
|
259
|
+
best = s;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return best;
|
|
263
|
+
}
|
|
264
|
+
function autoFlagsFor(counts, policy, etype) {
|
|
265
|
+
const byAction = new Map();
|
|
266
|
+
for (const [, [det]] of counts) {
|
|
267
|
+
const action = policy.actionFor(det.category, det.group);
|
|
268
|
+
if (action === 'allow')
|
|
269
|
+
continue;
|
|
270
|
+
if (!byAction.has(action))
|
|
271
|
+
byAction.set(action, []);
|
|
272
|
+
byAction.get(action).push([det.category, det.severity]);
|
|
273
|
+
}
|
|
274
|
+
const rows = [];
|
|
275
|
+
for (const action of ['block', 'redact', 'flag']) {
|
|
276
|
+
const items = byAction.get(action);
|
|
277
|
+
if (!items || items.length === 0)
|
|
278
|
+
continue;
|
|
279
|
+
const cats = [...new Set(items.map((i) => i[0]))].sort();
|
|
280
|
+
const verb = ACTION_VERB[action];
|
|
281
|
+
const severity = action === 'redact' ? 'info' : maxSeverity(items.map((i) => i[1]));
|
|
282
|
+
rows.push([`${verb} ${cats.join(', ')} from ${etype}`, verb, severity, cats]);
|
|
283
|
+
}
|
|
284
|
+
return rows;
|
|
285
|
+
}
|
|
286
|
+
// --------------------------------------------------------------------------- entry / log
|
|
287
|
+
/** One link in the hash chain. Field order == on-disk key order. */
|
|
288
|
+
export class AuditEntry {
|
|
289
|
+
seq;
|
|
290
|
+
ts;
|
|
291
|
+
type;
|
|
292
|
+
payload;
|
|
293
|
+
prev_hash;
|
|
294
|
+
hash;
|
|
295
|
+
sig;
|
|
296
|
+
constructor(seq, ts, type, payload, prev_hash, hash, sig = '') {
|
|
297
|
+
this.seq = seq;
|
|
298
|
+
this.ts = ts;
|
|
299
|
+
this.type = type;
|
|
300
|
+
this.payload = payload;
|
|
301
|
+
this.prev_hash = prev_hash;
|
|
302
|
+
this.hash = hash;
|
|
303
|
+
this.sig = sig;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function entryToRow(entry) {
|
|
307
|
+
return {
|
|
308
|
+
seq: entry.seq,
|
|
309
|
+
ts: entry.ts,
|
|
310
|
+
type: entry.type,
|
|
311
|
+
payload: entry.payload,
|
|
312
|
+
prev_hash: entry.prev_hash,
|
|
313
|
+
hash: entry.hash,
|
|
314
|
+
sig: entry.sig,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** The built-in redactor (secrets & email under the default policy). Also the built-in-path sentinel. */
|
|
318
|
+
const DEFAULT_POLICY = Policy.default();
|
|
319
|
+
export function defaultRedactor(obj) {
|
|
320
|
+
return redact(obj, DEFAULT_POLICY)[0];
|
|
321
|
+
}
|
|
322
|
+
/** A hash-chained, append-only, auto-populating audit log. */
|
|
323
|
+
export class AuditLog {
|
|
324
|
+
system;
|
|
325
|
+
riskTier;
|
|
326
|
+
entries = [];
|
|
327
|
+
_signingKey;
|
|
328
|
+
_redact;
|
|
329
|
+
_policy;
|
|
330
|
+
_redactor;
|
|
331
|
+
_flagOnRedact;
|
|
332
|
+
_maxEntries;
|
|
333
|
+
_path;
|
|
334
|
+
_storage;
|
|
335
|
+
_seq = 0;
|
|
336
|
+
_evictedFromMemory = 0;
|
|
337
|
+
_head = GENESIS;
|
|
338
|
+
constructor(system, opts = {}) {
|
|
339
|
+
const { riskTier = 'limited', path = null, signingKey = null, redact: redactOpt = true, redactor = null, flagOnRedact = true, policy = null, maxEntries = null, storage, } = opts;
|
|
340
|
+
if (maxEntries !== null && maxEntries < 1) {
|
|
341
|
+
throw new ValueError(`max_entries must be a positive int or None, got ${maxEntries}`);
|
|
342
|
+
}
|
|
343
|
+
this.system = system;
|
|
344
|
+
this.riskTier = riskTier;
|
|
345
|
+
this._signingKey = signingKey ?? null;
|
|
346
|
+
this._redact = redactOpt || policy !== null;
|
|
347
|
+
this._policy = policy ?? Policy.default();
|
|
348
|
+
this._redactor = redactor ?? defaultRedactor;
|
|
349
|
+
this._flagOnRedact = flagOnRedact;
|
|
350
|
+
this._path = path;
|
|
351
|
+
if (maxEntries !== null && path === null) {
|
|
352
|
+
process.emitWarning(new BoundedMemoryWithoutPathWarning('AuditLog(maxEntries) without path: evicted entries are lost because the file is the ' +
|
|
353
|
+
'source of truth. Pass path to keep the full chain on disk.'));
|
|
354
|
+
}
|
|
355
|
+
this._maxEntries = maxEntries;
|
|
356
|
+
this._storage = storage ?? (path !== null ? fsChainStorage(path) : memoryChainStorage());
|
|
357
|
+
this._append('audit_open', { system, risk_tier: riskTier });
|
|
358
|
+
bus.subscribe(this._onEvent);
|
|
359
|
+
}
|
|
360
|
+
/** The current chain head hash. Capture it to later assert completeness via `verify`. */
|
|
361
|
+
get head() {
|
|
362
|
+
return this._head;
|
|
363
|
+
}
|
|
364
|
+
/** Entries evicted from the in-memory ring by `maxEntries` (0 if unbounded). Never left the chain. */
|
|
365
|
+
get evictedFromMemory() {
|
|
366
|
+
return this._evictedFromMemory;
|
|
367
|
+
}
|
|
368
|
+
now() {
|
|
369
|
+
return new Date().toISOString();
|
|
370
|
+
}
|
|
371
|
+
// ------------------------------------------------------------------ chain
|
|
372
|
+
/** @internal Append one chain link. Also invoked by {@link Decision}. */
|
|
373
|
+
_append(etype, payload) {
|
|
374
|
+
const seq = this._seq;
|
|
375
|
+
this._seq += 1;
|
|
376
|
+
const ts = this.now();
|
|
377
|
+
let safe = jsonable(payload);
|
|
378
|
+
let autoFlags = [];
|
|
379
|
+
if (this._redact) {
|
|
380
|
+
if (this._redactor === defaultRedactor) {
|
|
381
|
+
const counts = scanCounts(safe);
|
|
382
|
+
if (counts.size > 0) {
|
|
383
|
+
const toScrub = new Set();
|
|
384
|
+
for (const [cat, [det]] of counts) {
|
|
385
|
+
const action = this._policy.actionFor(det.category, det.group);
|
|
386
|
+
if (action === 'redact' || action === 'block')
|
|
387
|
+
toScrub.add(cat);
|
|
388
|
+
}
|
|
389
|
+
if (toScrub.size > 0)
|
|
390
|
+
safe = scrub(safe, toScrub);
|
|
391
|
+
if (AUTO_REDACT_TYPES.has(etype) && this._flagOnRedact) {
|
|
392
|
+
autoFlags = autoFlagsFor(counts, this._policy, etype);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
safe = jsonable(this._redactor(safe));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const h = chainHash(this._head, seq, ts, etype, safe);
|
|
401
|
+
const sig = this._signingKey !== null ? hmacSha256Hex(this._signingKey, h) : '';
|
|
402
|
+
const entry = new AuditEntry(seq, ts, etype, safe, this._head, h, sig);
|
|
403
|
+
if (this._maxEntries !== null && this.entries.length === this._maxEntries) {
|
|
404
|
+
this._evictedFromMemory += 1;
|
|
405
|
+
this.entries.shift(); // drop the oldest, loudly counted above
|
|
406
|
+
}
|
|
407
|
+
this.entries.push(entry);
|
|
408
|
+
this._head = h;
|
|
409
|
+
this._storage.appendLine(`${dumpsDefault(entryToRow(entry))}\n`);
|
|
410
|
+
// OUTSIDE the "lock": each auto-flag is its own chained policy_flag entry (auto:true), tagged to
|
|
411
|
+
// the active decision. policy_flag is not an auto type, so this never recurses.
|
|
412
|
+
for (const [reason, action, severity, data] of autoFlags) {
|
|
413
|
+
this.flag(reason, { action, severity, data, extra: { auto: true } });
|
|
414
|
+
}
|
|
415
|
+
return entry;
|
|
416
|
+
}
|
|
417
|
+
// ------------------------------------------------------------------ auto-capture
|
|
418
|
+
_onEvent = (event) => {
|
|
419
|
+
const did = currentDecision();
|
|
420
|
+
if (event instanceof LLMCall) {
|
|
421
|
+
this._append('llm_call', {
|
|
422
|
+
decision_id: did,
|
|
423
|
+
provider: event.provider,
|
|
424
|
+
model: event.model,
|
|
425
|
+
usage: event.usage === null ? null : usageJsonable(event.usage),
|
|
426
|
+
cost: event.cost === null ? null : event.cost.toString(),
|
|
427
|
+
latency_ms: event.latencyMs === null ? null : new PyFloat(event.latencyMs),
|
|
428
|
+
replayed: Boolean(event.metadata?.replayed ?? false),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
else if (event instanceof ToolCall) {
|
|
432
|
+
this._append('tool_call', {
|
|
433
|
+
decision_id: did,
|
|
434
|
+
name: event.name,
|
|
435
|
+
arguments: jsonable(event.arguments),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
else if (event !== null &&
|
|
439
|
+
typeof event === 'object' &&
|
|
440
|
+
'decisions' in event &&
|
|
441
|
+
'budget' in event) {
|
|
442
|
+
const e = event;
|
|
443
|
+
this._append('context_assembly', {
|
|
444
|
+
decision_id: did,
|
|
445
|
+
model: e.model ?? null,
|
|
446
|
+
budget: e.budget,
|
|
447
|
+
used: e.used ?? null,
|
|
448
|
+
decisions: jsonable(e.decisions),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
/** Stop subscribing to the core event stream and close the log file handle (idempotent). */
|
|
453
|
+
detach() {
|
|
454
|
+
bus.unsubscribe(this._onEvent);
|
|
455
|
+
this._storage.close();
|
|
456
|
+
}
|
|
457
|
+
// ------------------------------------------------------------------ explicit events
|
|
458
|
+
/**
|
|
459
|
+
* Group a unit of work. Auto-captured calls inside the async callback are tagged with this
|
|
460
|
+
* decision (via an `AsyncLocalStorage` scope). Returns the callback's result.
|
|
461
|
+
*/
|
|
462
|
+
async decision(cb, opts = {}) {
|
|
463
|
+
const did = uuidHex();
|
|
464
|
+
const actor = opts.actor ?? 'agent';
|
|
465
|
+
this._append('decision', { decision_id: did, input: jsonable(opts.input ?? null), actor });
|
|
466
|
+
try {
|
|
467
|
+
return await activeDecision.run(did, async () => cb(new Decision(this, did)));
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
this._append('decision_end', { decision_id: did });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Record a policy flag — a tamper-evident record that a data/usage policy fired. `action`/`severity`
|
|
475
|
+
* are normalized to lowercase; pass a category label in `data`, never the raw value. Auto-tags the
|
|
476
|
+
* active decision span.
|
|
477
|
+
*/
|
|
478
|
+
flag(reason, opts = {}) {
|
|
479
|
+
return this._append('policy_flag', {
|
|
480
|
+
decision_id: currentDecision(),
|
|
481
|
+
reason,
|
|
482
|
+
action: String(opts.action ?? 'flagged').toLowerCase(),
|
|
483
|
+
severity: String(opts.severity ?? 'warning').toLowerCase(),
|
|
484
|
+
data: opts.data ?? null,
|
|
485
|
+
...(opts.extra ?? {}),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// ------------------------------------------------------------------ export
|
|
489
|
+
entriesForExport() {
|
|
490
|
+
if (this._evictedFromMemory === 0 || this._path === null)
|
|
491
|
+
return [...this.entries];
|
|
492
|
+
const entries = [];
|
|
493
|
+
for (const raw of this._storage.readLines()) {
|
|
494
|
+
const line = raw.trim();
|
|
495
|
+
if (!line)
|
|
496
|
+
continue;
|
|
497
|
+
const row = parsePreserving(line);
|
|
498
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row))
|
|
499
|
+
continue;
|
|
500
|
+
const obj = row;
|
|
501
|
+
if ('_meta' in obj)
|
|
502
|
+
continue; // a header from a previous export in the same file — skip
|
|
503
|
+
entries.push(new AuditEntry(obj.seq, obj.ts, obj.type, obj.payload ?? null, obj.prev_hash, obj.hash, obj.sig ?? ''));
|
|
504
|
+
}
|
|
505
|
+
return entries;
|
|
506
|
+
}
|
|
507
|
+
summary(entries) {
|
|
508
|
+
const countType = (t) => entries.filter((e) => e.type === t).length;
|
|
509
|
+
const flags = entries.filter((e) => e.type === 'policy_flag');
|
|
510
|
+
const counter = (values) => {
|
|
511
|
+
const m = {};
|
|
512
|
+
for (const v of values) {
|
|
513
|
+
const k = String(v);
|
|
514
|
+
m[k] = (m[k] ?? 0) + 1;
|
|
515
|
+
}
|
|
516
|
+
return m;
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
decisions: countType('decision'),
|
|
520
|
+
llm_calls: countType('llm_call'),
|
|
521
|
+
tool_calls: countType('tool_call'),
|
|
522
|
+
context_assemblies: countType('context_assembly'),
|
|
523
|
+
human_oversight: countType('human_oversight'),
|
|
524
|
+
policy_flags: flags.length,
|
|
525
|
+
flags_by_action: counter(flags.map((f) => f.payload.action)),
|
|
526
|
+
flags_by_severity: counter(flags.map((f) => f.payload.severity)),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/** Write the chain as a JSONL evidence pack, optionally annotated with framework control IDs. */
|
|
530
|
+
export(path, framework = null) {
|
|
531
|
+
if (framework && !(framework in CONTROLS)) {
|
|
532
|
+
throw new ValueError(`unknown framework '${framework}'; available: ${frameworks().join(', ')}`);
|
|
533
|
+
}
|
|
534
|
+
const controls = CONTROLS[framework ?? ''] ?? {};
|
|
535
|
+
const entries = this.entriesForExport();
|
|
536
|
+
const coveredSet = new Set();
|
|
537
|
+
for (const e of entries) {
|
|
538
|
+
for (const c of controlsForEntry(e, framework ?? '', controls))
|
|
539
|
+
coveredSet.add(c);
|
|
540
|
+
}
|
|
541
|
+
const covered = [...coveredSet].sort();
|
|
542
|
+
const out = fsChainStorage(path);
|
|
543
|
+
try {
|
|
544
|
+
const metaBody = {
|
|
545
|
+
system: this.system,
|
|
546
|
+
risk_tier: this.riskTier,
|
|
547
|
+
framework: framework,
|
|
548
|
+
controls_covered: covered,
|
|
549
|
+
summary: this.summary(entries),
|
|
550
|
+
head_hash: this._head,
|
|
551
|
+
entries: entries.length,
|
|
552
|
+
disclaimer: 'Evidence to support compliance — not legal advice.',
|
|
553
|
+
};
|
|
554
|
+
if (this._signingKey !== null) {
|
|
555
|
+
metaBody.sig = metaSignature(this._signingKey, metaBody);
|
|
556
|
+
}
|
|
557
|
+
out.appendLine(`${dumpsDefault({ _meta: metaBody })}\n`);
|
|
558
|
+
for (const entry of entries) {
|
|
559
|
+
const row = entryToRow(entry);
|
|
560
|
+
if (framework)
|
|
561
|
+
row.controls = controlsForEntry(entry, framework, controls);
|
|
562
|
+
out.appendLine(`${dumpsDefault(row)}\n`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
finally {
|
|
566
|
+
out.close();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Handle for the active decision span (passed to the {@link AuditLog.decision} callback). */
|
|
571
|
+
export class Decision {
|
|
572
|
+
log;
|
|
573
|
+
id;
|
|
574
|
+
constructor(log, id) {
|
|
575
|
+
this.log = log;
|
|
576
|
+
this.id = id;
|
|
577
|
+
}
|
|
578
|
+
/** Record decision metadata (e.g. `{ model, prompt_id }`). */
|
|
579
|
+
record(fields) {
|
|
580
|
+
this.log._append('decision_record', { decision_id: this.id, ...fields });
|
|
581
|
+
}
|
|
582
|
+
/** Record an Art. 14-style human-oversight event: who reviewed, what action, and a note. */
|
|
583
|
+
humanOversight(reviewer, action, note = '') {
|
|
584
|
+
this.log._append('human_oversight', {
|
|
585
|
+
decision_id: this.id,
|
|
586
|
+
reviewer,
|
|
587
|
+
action,
|
|
588
|
+
note,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/** Record a policy flag tagged to this decision. Returns the chained {@link AuditEntry}. */
|
|
592
|
+
flag(reason, opts = {}) {
|
|
593
|
+
return this.log._append('policy_flag', {
|
|
594
|
+
decision_id: this.id,
|
|
595
|
+
reason,
|
|
596
|
+
action: String(opts.action ?? 'flagged').toLowerCase(),
|
|
597
|
+
severity: String(opts.severity ?? 'warning').toLowerCase(),
|
|
598
|
+
data: opts.data ?? null,
|
|
599
|
+
...(opts.extra ?? {}),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function uuidHex() {
|
|
604
|
+
return globalThis.crypto.randomUUID().replace(/-/g, '');
|
|
605
|
+
}
|
|
606
|
+
function errMessage(e) {
|
|
607
|
+
return e instanceof Error ? e.message : String(e);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Re-walk the hash chain in a JSONL file. Returns `[ok, detail]`. Detects edits and deletions,
|
|
611
|
+
* including tail-truncation. Never throws on a missing/corrupt file — returns `[false, detail]`.
|
|
612
|
+
*/
|
|
613
|
+
export function verify(path, opts = {}) {
|
|
614
|
+
const key = opts.key ?? null;
|
|
615
|
+
const expectedHead = opts.expectedHead ?? null;
|
|
616
|
+
const expectEntries = opts.expectEntries ?? null;
|
|
617
|
+
let prev = GENESIS;
|
|
618
|
+
let seen = 0;
|
|
619
|
+
let meta = null;
|
|
620
|
+
let text;
|
|
621
|
+
try {
|
|
622
|
+
text = fsReadLines(path).join('\n');
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
return [false, `cannot read ${path}: ${errMessage(e)}`];
|
|
626
|
+
}
|
|
627
|
+
// Split on the record separator only (not on Unicode line separators inside JSON strings).
|
|
628
|
+
for (const raw of text.split('\n')) {
|
|
629
|
+
const line = raw.trim();
|
|
630
|
+
if (!line)
|
|
631
|
+
continue;
|
|
632
|
+
let row;
|
|
633
|
+
try {
|
|
634
|
+
row = parsePreserving(line);
|
|
635
|
+
}
|
|
636
|
+
catch (e) {
|
|
637
|
+
return [false, `corrupt log ${path}: ${errMessage(e)}`];
|
|
638
|
+
}
|
|
639
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
|
|
640
|
+
return [false, `corrupt log ${path}: line is not an object`];
|
|
641
|
+
}
|
|
642
|
+
const obj = row;
|
|
643
|
+
if ('_meta' in obj) {
|
|
644
|
+
const m = obj._meta;
|
|
645
|
+
meta =
|
|
646
|
+
m !== null && typeof m === 'object' && !Array.isArray(m)
|
|
647
|
+
? m
|
|
648
|
+
: {};
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
for (const field of ['seq', 'ts', 'type', 'payload', 'prev_hash', 'hash']) {
|
|
652
|
+
if (!(field in obj))
|
|
653
|
+
return [false, `corrupt log ${path}: missing '${field}'`];
|
|
654
|
+
}
|
|
655
|
+
const seqStr = String(obj.seq);
|
|
656
|
+
const expected = chainHash(prev, obj.seq ?? null, obj.ts ?? null, obj.type ?? null, obj.payload ?? null);
|
|
657
|
+
if (obj.prev_hash !== prev) {
|
|
658
|
+
return [false, `broken link at seq ${seqStr}: prev_hash mismatch`];
|
|
659
|
+
}
|
|
660
|
+
if (obj.hash !== expected) {
|
|
661
|
+
return [false, `tampered entry at seq ${seqStr}: hash mismatch`];
|
|
662
|
+
}
|
|
663
|
+
if (key !== null) {
|
|
664
|
+
const want = hmacSha256Hex(key, obj.hash);
|
|
665
|
+
const sig = typeof obj.sig === 'string' ? obj.sig : '';
|
|
666
|
+
if (!timingSafeEqualHex(sig, want)) {
|
|
667
|
+
return [false, `bad signature at seq ${seqStr}`];
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
prev = obj.hash;
|
|
671
|
+
seen += 1;
|
|
672
|
+
}
|
|
673
|
+
const metaHead = meta !== null ? (meta.head_hash ?? null) : null;
|
|
674
|
+
const metaEntries = meta !== null ? (meta.entries ?? null) : null;
|
|
675
|
+
let metaTrusted = false;
|
|
676
|
+
if (key !== null && meta !== null) {
|
|
677
|
+
const providedSig = typeof meta.sig === 'string' ? meta.sig : '';
|
|
678
|
+
if (!providedSig) {
|
|
679
|
+
return [false, 'unauthenticated _meta: signed log but header carries no signature'];
|
|
680
|
+
}
|
|
681
|
+
if (!timingSafeEqualHex(providedSig, metaSignature(key, meta))) {
|
|
682
|
+
return [false, 'forged _meta: header signature mismatch (completeness fields altered?)'];
|
|
683
|
+
}
|
|
684
|
+
metaTrusted = true;
|
|
685
|
+
}
|
|
686
|
+
const wantHead = expectedHead !== null ? expectedHead : metaHead;
|
|
687
|
+
if (wantHead !== null && prev !== wantHead) {
|
|
688
|
+
const wh = String(wantHead).slice(0, 12);
|
|
689
|
+
return [
|
|
690
|
+
false,
|
|
691
|
+
`incomplete log: head ${prev.slice(0, 12)}… != expected ${wh}… (trailing entries removed?)`,
|
|
692
|
+
];
|
|
693
|
+
}
|
|
694
|
+
let wantN = null;
|
|
695
|
+
if (expectEntries !== null)
|
|
696
|
+
wantN = BigInt(expectEntries);
|
|
697
|
+
else if (typeof metaEntries === 'bigint')
|
|
698
|
+
wantN = metaEntries;
|
|
699
|
+
else if (typeof metaEntries === 'number')
|
|
700
|
+
wantN = BigInt(metaEntries);
|
|
701
|
+
if (wantN !== null && BigInt(seen) !== wantN) {
|
|
702
|
+
return [false, `incomplete log: found ${seen} entries, expected ${wantN} (entries removed?)`];
|
|
703
|
+
}
|
|
704
|
+
const notes = [];
|
|
705
|
+
if (key !== null) {
|
|
706
|
+
notes.push('signatures verified');
|
|
707
|
+
if (metaTrusted)
|
|
708
|
+
notes.push('metadata signature verified');
|
|
709
|
+
}
|
|
710
|
+
else if (meta !== null && expectedHead === null && expectEntries === null) {
|
|
711
|
+
notes.push('completeness from unauthenticated in-file _meta — pass expected_head/expect_entries ' +
|
|
712
|
+
'out-of-band for an authoritative check');
|
|
713
|
+
}
|
|
714
|
+
const suffix = notes.length ? ` (${notes.join('; ')})` : '';
|
|
715
|
+
return [true, `ok: ${seen} entries, head ${prev.slice(0, 12)}…${suffix}`];
|
|
716
|
+
}
|
|
717
|
+
// --------------------------------------------------------------------------- CLI
|
|
718
|
+
/** `acttrace verify <path> [--key K] [--expect-head H] [--expect-entries N]`. Returns an exit code. */
|
|
719
|
+
export function main(argv) {
|
|
720
|
+
if (argv[0] !== 'verify' || argv.length < 2)
|
|
721
|
+
return 2;
|
|
722
|
+
const path = argv[1];
|
|
723
|
+
let key = null;
|
|
724
|
+
let expectedHead = null;
|
|
725
|
+
let expectEntries = null;
|
|
726
|
+
for (let i = 2; i < argv.length; i++) {
|
|
727
|
+
const a = argv[i];
|
|
728
|
+
if (a === '--key')
|
|
729
|
+
key = argv[++i] ?? null;
|
|
730
|
+
else if (a === '--expect-head')
|
|
731
|
+
expectedHead = argv[++i] ?? null;
|
|
732
|
+
else if (a === '--expect-entries') {
|
|
733
|
+
const v = argv[++i];
|
|
734
|
+
expectEntries = v === undefined ? null : Number.parseInt(v, 10);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const [ok, detail] = verify(path, { key, expectedHead, expectEntries });
|
|
738
|
+
process.stdout.write(`${detail}\n`);
|
|
739
|
+
return ok ? 0 : 1;
|
|
740
|
+
}
|
|
741
|
+
//# sourceMappingURL=index.js.map
|