attenu-guard 0.3.1 → 0.5.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +282 -0
  2. package/dist/cjs/adapters/langgraph.d.ts +214 -3
  3. package/dist/cjs/adapters/langgraph.d.ts.map +1 -1
  4. package/dist/cjs/adapters/langgraph.js +588 -12
  5. package/dist/cjs/adapters/langgraph.js.map +1 -1
  6. package/dist/cjs/audit.d.ts +38 -1
  7. package/dist/cjs/audit.d.ts.map +1 -1
  8. package/dist/cjs/audit.js +52 -8
  9. package/dist/cjs/audit.js.map +1 -1
  10. package/dist/cjs/chain.d.ts +32 -0
  11. package/dist/cjs/chain.d.ts.map +1 -1
  12. package/dist/cjs/chain.js +0 -0
  13. package/dist/cjs/chain.js.map +1 -1
  14. package/dist/cjs/evidence.d.ts +38 -3
  15. package/dist/cjs/evidence.d.ts.map +1 -1
  16. package/dist/cjs/evidence.js +523 -13
  17. package/dist/cjs/evidence.js.map +1 -1
  18. package/dist/cjs/guard.d.ts +163 -9
  19. package/dist/cjs/guard.d.ts.map +1 -1
  20. package/dist/cjs/guard.js +444 -26
  21. package/dist/cjs/guard.js.map +1 -1
  22. package/dist/cjs/index.d.ts +10 -7
  23. package/dist/cjs/index.d.ts.map +1 -1
  24. package/dist/cjs/index.js +21 -5
  25. package/dist/cjs/index.js.map +1 -1
  26. package/dist/cjs/params.d.ts +52 -0
  27. package/dist/cjs/params.d.ts.map +1 -0
  28. package/dist/cjs/params.js +97 -0
  29. package/dist/cjs/params.js.map +1 -0
  30. package/dist/cjs/reasons.d.ts +89 -1
  31. package/dist/cjs/reasons.d.ts.map +1 -1
  32. package/dist/cjs/reasons.js +99 -3
  33. package/dist/cjs/reasons.js.map +1 -1
  34. package/dist/cjs/version.d.ts +10 -0
  35. package/dist/cjs/version.d.ts.map +1 -0
  36. package/dist/cjs/version.js +13 -0
  37. package/dist/cjs/version.js.map +1 -0
  38. package/dist/esm/adapters/langgraph.d.ts +214 -3
  39. package/dist/esm/adapters/langgraph.d.ts.map +1 -1
  40. package/dist/esm/adapters/langgraph.js +586 -11
  41. package/dist/esm/adapters/langgraph.js.map +1 -1
  42. package/dist/esm/audit.d.ts +38 -1
  43. package/dist/esm/audit.d.ts.map +1 -1
  44. package/dist/esm/audit.js +51 -8
  45. package/dist/esm/audit.js.map +1 -1
  46. package/dist/esm/chain.d.ts +32 -0
  47. package/dist/esm/chain.d.ts.map +1 -1
  48. package/dist/esm/chain.js +0 -0
  49. package/dist/esm/chain.js.map +1 -1
  50. package/dist/esm/evidence.d.ts +38 -3
  51. package/dist/esm/evidence.d.ts.map +1 -1
  52. package/dist/esm/evidence.js +523 -13
  53. package/dist/esm/evidence.js.map +1 -1
  54. package/dist/esm/guard.d.ts +163 -9
  55. package/dist/esm/guard.d.ts.map +1 -1
  56. package/dist/esm/guard.js +411 -27
  57. package/dist/esm/guard.js.map +1 -1
  58. package/dist/esm/index.d.ts +10 -7
  59. package/dist/esm/index.d.ts.map +1 -1
  60. package/dist/esm/index.js +6 -4
  61. package/dist/esm/index.js.map +1 -1
  62. package/dist/esm/params.d.ts +52 -0
  63. package/dist/esm/params.d.ts.map +1 -0
  64. package/dist/esm/params.js +92 -0
  65. package/dist/esm/params.js.map +1 -0
  66. package/dist/esm/reasons.d.ts +89 -1
  67. package/dist/esm/reasons.d.ts.map +1 -1
  68. package/dist/esm/reasons.js +97 -2
  69. package/dist/esm/reasons.js.map +1 -1
  70. package/dist/esm/version.d.ts +10 -0
  71. package/dist/esm/version.d.ts.map +1 -0
  72. package/dist/esm/version.js +10 -0
  73. package/dist/esm/version.js.map +1 -0
  74. package/package.json +1 -1
package/dist/cjs/guard.js CHANGED
@@ -28,14 +28,96 @@
28
28
  * (`spawn`/`spawn_denied`/`kill`) even though the API reads
29
29
  * issue/delegate/revoke. That field is a separately-versioned published wire
30
30
  * contract that verifiers depend on.
31
+ *
32
+ * ## Execution binding (0.9.0, `Guard.issue(agentId, authority, {schemaVersion: 2})` only)
33
+ *
34
+ * `schemaVersion: 1` chains (the default — nothing below applies to them) behave EXACTLY as they
35
+ * did before 0.9.0: no `callId`, no pending tracking, no `NODE_FINALIZED` refusal, `check`'s new
36
+ * `authorizedParams`/`capture`/`adapter` options throw if supplied. A caller opts in per chain,
37
+ * once, at `Guard.issue` — schema versions never mix within a chain
38
+ * (docs/execution-binding spec section 9).
39
+ *
40
+ * On a `schemaVersion: 2` chain, `check`'s transition is, in order:
41
+ *
42
+ * 1. refuse if the node is already `complete()`d (`ReasonCode.NODE_FINALIZED`);
43
+ * 2. otherwise evaluate authority/ceilings and update meters (`evaluate` + auto-metering,
44
+ * unchanged from schema version 1);
45
+ * 3. allocate `callId` — 16 bytes from `crypto.randomBytes`, lowercase hex; if that throws, the
46
+ * call is denied and NOTHING is appended (`ReasonCode.CALL_ID_UNAVAILABLE`);
47
+ * 4. commit the entry (append to the audit log — may throw `CommittedAuditError` if persistence
48
+ * fails AFTER the in-memory commit; this method attaches `.decision` to that error before it
49
+ * propagates, per spec section 1: "carries the committed entry and the decision");
50
+ * 5. register an allowed call as pending (even across a `CommittedAuditError` — spec: "the
51
+ * guard registers an allowed call as pending before raising");
52
+ * 6. return the `Decision`, which now carries `.callId`.
53
+ *
54
+ * `recordOutcome` is the producer API a body-owning wrapper calls once it knows how the call
55
+ * ended; `complete` refuses (returns a falsy-in-`==` `CompletionResult`) while calls are still
56
+ * pending; `revoke`/`revokeAgent` snapshot the still-pending callIds onto the `kill` entry as
57
+ * `pending_at_kill` without clearing them — a late `recordOutcome` after a kill is accepted.
58
+ *
59
+ * JavaScript has no analogue of Python's `__bool__`, so unlike the Python library,
60
+ * `if (guard.complete())` cannot be made to read `false` on refusal — see `CompletionResult`'s
61
+ * own doc comment in reasons.ts for exactly what IS and is not bridged.
31
62
  */
63
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
64
+ if (k2 === undefined) k2 = k;
65
+ var desc = Object.getOwnPropertyDescriptor(m, k);
66
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
67
+ desc = { enumerable: true, get: function() { return m[k]; } };
68
+ }
69
+ Object.defineProperty(o, k2, desc);
70
+ }) : (function(o, m, k, k2) {
71
+ if (k2 === undefined) k2 = k;
72
+ o[k2] = m[k];
73
+ }));
74
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
75
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
76
+ }) : function(o, v) {
77
+ o["default"] = v;
78
+ });
79
+ var __importStar = (this && this.__importStar) || (function () {
80
+ var ownKeys = function(o) {
81
+ ownKeys = Object.getOwnPropertyNames || function (o) {
82
+ var ar = [];
83
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
84
+ return ar;
85
+ };
86
+ return ownKeys(o);
87
+ };
88
+ return function (mod) {
89
+ if (mod && mod.__esModule) return mod;
90
+ var result = {};
91
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
92
+ __setModuleDefault(result, mod);
93
+ return result;
94
+ };
95
+ })();
32
96
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.Guard = exports.AuthorityDenied = void 0;
97
+ exports.Guard = exports.DuplicateOutcomeError = exports.AuthorityDenied = void 0;
34
98
  const authority_js_1 = require("./authority.js");
35
99
  const audit_js_1 = require("./audit.js");
36
100
  const chain_js_1 = require("./chain.js");
37
101
  const ceilings_js_1 = require("./ceilings.js");
38
102
  const reasons_js_1 = require("./reasons.js");
103
+ const paramsMod = __importStar(require("./params.js"));
104
+ const params_js_1 = require("./params.js");
105
+ const version_js_1 = require("./version.js");
106
+ const node_crypto_1 = require("node:crypto");
107
+ /**
108
+ * Capture modes that promise a terminal observation of the body — the only ones a call should
109
+ * ever be registered PENDING for. `Capture.PRE_HOOK_ONLY` (the Guard's own default for a bare
110
+ * v2 `check()`, and any adapter's explicit choice) is honest about NOT observing completion —
111
+ * nothing will ever call `recordOutcome` for it, so treating it as pending would wedge
112
+ * `complete()` forever (mirrors the Python D14 fix, guard.py `_TERMINAL_CAPTURES`). The offline
113
+ * verifier already treats a missing `PRE_HOOK_ONLY` outcome as merely `unobserved`
114
+ * (evidence.ts); runtime pending-tracking now agrees.
115
+ */
116
+ const TERMINAL_CAPTURES = new Set([
117
+ reasons_js_1.Capture.WRAPPER_SYNC,
118
+ reasons_js_1.Capture.WRAPPER_ASYNC,
119
+ reasons_js_1.Capture.FRAMEWORK_POST_HOOK,
120
+ ]);
39
121
  /**
40
122
  * Thrown only by `enforce`. Carries the full `Decision`, so a caller can branch
41
123
  * on `err.decision.reasons[0].code` instead of parsing a message string.
@@ -49,6 +131,19 @@ class AuthorityDenied extends Error {
49
131
  }
50
132
  }
51
133
  exports.AuthorityDenied = AuthorityDenied;
134
+ /**
135
+ * Thrown by `Guard.recordOutcome` when `callId` already has a recorded outcome in this chain's
136
+ * lifetime. A programming error in the caller (a wrapper observing the same call twice), not a
137
+ * policy outcome — "exactly one outcome per callId, enforced at append" (docs/execution-binding
138
+ * spec section 3).
139
+ */
140
+ class DuplicateOutcomeError extends Error {
141
+ constructor(message) {
142
+ super(message);
143
+ this.name = "DuplicateOutcomeError";
144
+ }
145
+ }
146
+ exports.DuplicateOutcomeError = DuplicateOutcomeError;
52
147
  /**
53
148
  * A monotonic integer for audit sequencing, distinct from the wall clock used
54
149
  * for TTL expiry — it keeps the log's ordering deterministic regardless of
@@ -77,9 +172,27 @@ class Guard {
77
172
  this.strikes = strikes;
78
173
  }
79
174
  // ---- factory ----------------------------------------------------------
80
- /** A fresh root Guard, starting a new delegation chain. */
175
+ /**
176
+ * A fresh root Guard, starting a new delegation chain. `schemaVersion: 2` opts the whole chain
177
+ * into 0.9.0 execution binding — see the module doc comment.
178
+ *
179
+ * `auditOverwrite: true` (silently replace an existing non-empty ledger at `auditPath`) is
180
+ * REFUSED on a `schemaVersion: 2` chain: the restart rule has no escape hatch on v2 — a v2
181
+ * process restart must always open a NEW audit path, never overwrite an old one, so that a
182
+ * pending call from before the restart stays truthfully unaccounted rather than vanishing
183
+ * under a fresh chain at the same path. v1 keeps the flag exactly as before.
184
+ */
81
185
  static issue(agentId, authority, options = {}) {
82
186
  const chainId = options.chainId ?? "chain";
187
+ const schemaVersion = options.schemaVersion ?? 1;
188
+ if (schemaVersion !== 1 && schemaVersion !== 2) {
189
+ throw new Error(`unsupported schemaVersion ${schemaVersion}; expected 1 or 2`);
190
+ }
191
+ if (schemaVersion === 2 && options.auditOverwrite) {
192
+ throw new Error("auditOverwrite: true is not permitted on a schemaVersion: 2 chain — the restart rule " +
193
+ "has no escape hatch on v2 (docs/execution-binding spec section 1). Open a new audit " +
194
+ "path for the new chain instead; v1 chains may still set auditOverwrite: true.");
195
+ }
83
196
  const chain = new chain_js_1.Chain(chainId, {
84
197
  maxDepth: options.maxDepth ?? 6,
85
198
  maxFanout: options.maxFanout ?? 16,
@@ -88,15 +201,22 @@ class Guard {
88
201
  const audit = new audit_js_1.AuditLog({
89
202
  path: options.auditPath ?? null,
90
203
  sinks: options.auditSinks ?? [],
204
+ schemaVersion,
205
+ overwrite: options.auditOverwrite ?? false,
91
206
  });
92
207
  const seq = new SeqClock();
93
208
  const node = chain.addRoot(agentId, authority, options.task ?? "root");
94
- audit.append("root", seq.now(), {
209
+ const rootFields = {
95
210
  chain_id: chainId,
96
211
  node: node.nodeId,
97
212
  agent: agentId,
98
213
  authority: authority.toWire(),
99
- });
214
+ };
215
+ if (schemaVersion === 2) {
216
+ chain.paramsSalt = (0, node_crypto_1.randomBytes)(16);
217
+ rootFields["params_salt"] = chain.paramsSalt.toString("hex");
218
+ }
219
+ audit.append("root", seq.now(), rootFields);
100
220
  return new Guard(node, chain, audit, seq, options.strictMetering ?? false, options.strikes ?? null);
101
221
  }
102
222
  // ---- identity ---------------------------------------------------------
@@ -125,6 +245,18 @@ class Guard {
125
245
  get isComplete() {
126
246
  return this.node.complete;
127
247
  }
248
+ /**
249
+ * 0.9.0: the schema version this Guard's chain was issued at (1 or 2 — see
250
+ * `Guard.issue({schemaVersion})`). Adapters use this to decide whether to pass
251
+ * `capture`/`adapter`/`authorizedParams` to `check` and call `recordOutcome` afterwards,
252
+ * rather than reaching into the audit log directly.
253
+ */
254
+ get schemaVersion() {
255
+ return this.audit.schemaVersion;
256
+ }
257
+ get isV2() {
258
+ return this.schemaVersion === 2;
259
+ }
128
260
  /** Is `other` an ancestor of this guard in the same chain? */
129
261
  isDescendantOf(other) {
130
262
  if (other.chain !== this.chain)
@@ -138,21 +270,34 @@ class Guard {
138
270
  return false;
139
271
  }
140
272
  /**
141
- * Mark this node's work FINISHED — one `done` event, idempotent. Purely a
142
- * lifecycle marker for the ledger and downstream analytics (a delegation that
143
- * never reached `done` was cut short); it does NOT change authority.
144
- * Revocation is the hard stop.
273
+ * Mark this node's work FINISHED — one `done` event.
274
+ *
275
+ * On a `schemaVersion: 2` chain, returns a `CompletionResult` (see its own doc comment in
276
+ * reasons.ts for the JavaScript-specific limits of its truthiness bridge) and refuses — a
277
+ * falsy-by-`.completed` `CompletionResult` carrying `.pendingCallIds` — while this node has
278
+ * `allow`ed calls that have not yet reported an outcome; completing while a call is still open
279
+ * would be a false claim that the node's work is finished. Idempotent:
280
+ * `CompletionResult(false, [])` if already marked.
281
+ *
282
+ * On a `schemaVersion: 1` chain (the default) this returns a plain `boolean`, byte-and-type
283
+ * IDENTICAL to every release before 0.9.0 — v1 never gained pending-call awareness, so there is
284
+ * nothing new to report and no reason to change its return type. Purely a lifecycle marker
285
+ * either way — it does NOT change authority; revocation is the hard stop.
145
286
  */
146
287
  complete() {
288
+ const v2 = this.isV2;
147
289
  if (this.node.complete)
148
- return false;
290
+ return v2 ? new reasons_js_1.CompletionResult(false, []) : false;
291
+ const pending = v2 ? this.chain.pendingFor(this.node.nodeId) : [];
292
+ if (pending.length > 0)
293
+ return new reasons_js_1.CompletionResult(false, pending); // only reachable on v2
149
294
  this.node.complete = true;
150
295
  this.append("done", {
151
296
  chain_id: this.chainId,
152
297
  node: this.node.nodeId,
153
298
  agent: this.node.agentId,
154
299
  });
155
- return true;
300
+ return v2 ? new reasons_js_1.CompletionResult(true, []) : true;
156
301
  }
157
302
  // ---- delegation -------------------------------------------------------
158
303
  /**
@@ -263,7 +408,7 @@ class Guard {
263
408
  `${Array.from(reasons_js_1.DISPOSITIONS).sort().join(", ")}`);
264
409
  }
265
410
  }
266
- logDecision(decision, scope, tool, context, disposition) {
411
+ logDecision(decision, scope, tool, context, disposition, extraFields) {
267
412
  const event = decision.allowed ? "allow" : "deny";
268
413
  const fields = {
269
414
  chain_id: this.chainId,
@@ -287,7 +432,9 @@ class Guard {
287
432
  if (d !== null)
288
433
  fields["disposition"] = d;
289
434
  }
290
- this.append(event, fields);
435
+ if (extraFields)
436
+ Object.assign(fields, extraFields);
437
+ return this.append(event, fields);
291
438
  }
292
439
  // ---- enforcement ------------------------------------------------------
293
440
  callLimits() {
@@ -311,6 +458,37 @@ class Guard {
311
458
  }
312
459
  return filled;
313
460
  }
461
+ static attachCallId(decision, callId) {
462
+ return callId === null ? decision : decision.withCallId(callId);
463
+ }
464
+ /**
465
+ * `[hashHex, reason]` against this chain's `paramsSalt` — see params.ts. `[null, null]` if the
466
+ * caller omitted the option entirely (opted out of this specific commitment).
467
+ */
468
+ paramsCommitment(value) {
469
+ if (value === undefined)
470
+ return [null, null];
471
+ const salt = this.chain.paramsSalt;
472
+ if (salt === null)
473
+ return [null, params_js_1.ParamsHashReason.UNSUPPORTED]; // cannot happen for a properly-issued v2 chain
474
+ return paramsMod.commit(value, salt);
475
+ }
476
+ static validateCaptureAdapter(capture, adapter) {
477
+ if (capture !== null && !reasons_js_1.CAPTURES.has(capture)) {
478
+ throw new Error(`unknown capture ${JSON.stringify(capture)}; expected one of ` +
479
+ `${Array.from(reasons_js_1.CAPTURES).sort().join(", ")}`);
480
+ }
481
+ if (capture !== null && adapter === null) {
482
+ throw new Error("adapter: {module, version, hookPath} is required alongside capture " +
483
+ "(docs/execution-binding spec section 2)");
484
+ }
485
+ if (adapter !== null) {
486
+ const missing = ["module", "version", "hookPath"].filter((k) => !(k in adapter));
487
+ if (missing.length > 0) {
488
+ throw new Error(`adapter is missing ${JSON.stringify(missing)}; expected module/version/hookPath`);
489
+ }
490
+ }
491
+ }
314
492
  /**
315
493
  * Authorize an action. Returns a `Decision`; it does not throw on a denial.
316
494
  * Every call — allow or deny — is appended to the audit log.
@@ -319,33 +497,138 @@ class Guard {
319
497
  * supply `calls`, the guard supplies the running count for (node, pattern)
320
498
  * itself, including this call, and increments it on allow. An explicit
321
499
  * `calls` in the context always wins.
500
+ *
501
+ * `authorizedParams`/`capture`/`adapter` (0.9.0, `schemaVersion: 2` chains only — throws
502
+ * otherwise): the execution-binding inputs, see the module doc comment and
503
+ * docs/execution-binding spec sections 1-4. On a schema-version-2 chain, the returned
504
+ * `Decision.callId` is what a later `recordOutcome` call binds to.
322
505
  */
323
506
  check(scope, options = {}) {
324
507
  const disposition = options.disposition ?? null;
325
508
  Guard.checkDisposition(disposition); // refuse before anything reaches the ledger
509
+ const isV2 = this.isV2;
510
+ const authorizedParams = options.authorizedParams;
511
+ const capture = options.capture ?? null;
512
+ const adapter = options.adapter ?? null;
513
+ if (!isV2 && (authorizedParams !== undefined || capture !== null || adapter !== null)) {
514
+ throw new Error("authorizedParams/capture/adapter require a schemaVersion: 2 chain " +
515
+ "(Guard.issue(..., {schemaVersion: 2}))");
516
+ }
517
+ Guard.validateCaptureAdapter(capture, adapter);
326
518
  const ctx = { ...(options.context ?? {}) };
327
- const filled = this.autoMeter(scope, ctx);
328
- const decision = this.evaluate(scope, ctx, options.metered ?? false);
329
- if (decision.allowed) {
330
- for (const c of filled)
331
- this.chain.countCall(this.node.nodeId, c.meterKey ?? "*");
519
+ const nid = this.node.nodeId;
520
+ // 1. refuse if the node is finalized (v2 only — a v1 chain's `complete()` has always been a
521
+ // pure informational marker that leaves authority, and `check`, untouched).
522
+ let decision;
523
+ let filled;
524
+ if (isV2 && this.node.complete) {
525
+ decision = reasons_js_1.Decision.deny(new reasons_js_1.Reason(reasons_js_1.ReasonCode.NODE_FINALIZED, { message: "node already finalized (complete())" }), nid);
526
+ filled = [];
527
+ }
528
+ else {
529
+ // 2. evaluate authority/ceilings; update meters on allow.
530
+ filled = this.autoMeter(scope, ctx);
531
+ decision = this.evaluate(scope, ctx, options.metered ?? false);
532
+ if (decision.allowed) {
533
+ for (const c of filled)
534
+ this.chain.countCall(nid, c.meterKey ?? "*");
535
+ }
536
+ }
537
+ // 3. allocate callId (v2 only) — fail-closed, nothing written, if the CSPRNG throws.
538
+ let callId = null;
539
+ if (isV2) {
540
+ try {
541
+ callId = (0, node_crypto_1.randomBytes)(16).toString("hex");
542
+ }
543
+ catch (exc) {
544
+ // Pre-commit failure (spec section 1): meters are restored, nothing is pending, the call
545
+ // is denied, nothing is appended.
546
+ if (decision.allowed) {
547
+ for (const c of filled)
548
+ this.chain.uncountCall(nid, c.meterKey ?? "*");
549
+ }
550
+ return reasons_js_1.Decision.deny(new reasons_js_1.Reason(reasons_js_1.ReasonCode.CALL_ID_UNAVAILABLE, { message: String(exc) }), nid);
551
+ }
332
552
  }
333
- this.logDecision(decision, scope, options.tool ?? null, ctx, disposition);
553
+ const extra = {};
554
+ if (isV2) {
555
+ extra["call_id"] = callId;
556
+ if (decision.allowed) {
557
+ if (capture !== null) {
558
+ extra["capture"] = capture;
559
+ extra["adapter"] = {
560
+ module: adapter.module,
561
+ version: adapter.version,
562
+ hook_path: adapter.hookPath,
563
+ };
564
+ }
565
+ else {
566
+ // A bare check() with no wrapper IS itself an observation, honestly described:
567
+ // authorization was observed; execution was not. The guard supplies this default
568
+ // rather than leaving capture/adapter absent, so every v2 allow carries them — the
569
+ // verifier requires both (merge-gate item 4); the caller-facing API stays optional,
570
+ // the ledger is not.
571
+ extra["capture"] = reasons_js_1.Capture.PRE_HOOK_ONLY;
572
+ extra["adapter"] = { module: "attenu-guard", version: version_js_1.VERSION, hook_path: "Guard.check" };
573
+ }
574
+ const [ph, preason] = this.paramsCommitment(authorizedParams);
575
+ if (ph !== null)
576
+ extra["authorized_params_hash"] = ph;
577
+ else if (preason !== null)
578
+ extra["params_hash_reason"] = preason;
579
+ }
580
+ }
581
+ // 4. commit (append) — a post-commit persistence failure throws CommittedAuditError; attach
582
+ // `.decision` (spec: "carries the committed entry and the decision") before it propagates,
583
+ // and still register the pending call first (step 5). ANY OTHER exception here (e.g. a
584
+ // canonicalization failure while hashing the entry, inside AuditLog.append's hashEntry
585
+ // call, which runs BEFORE its commit point) is a pre-commit failure exactly like the
586
+ // CSPRNG case above: meters are restored, nothing is pending, and the exception is
587
+ // re-thrown as-is (not swallowed into a Decision — unlike CSPRNG exhaustion, a malformed
588
+ // context/authorizedParams value is the caller's error, and this library's convention
589
+ // elsewhere is to throw on malformed input, not silently deny).
590
+ try {
591
+ this.logDecision(decision, scope, options.tool ?? null, ctx, disposition, extra);
592
+ }
593
+ catch (exc) {
594
+ if (exc instanceof audit_js_1.CommittedAuditError) {
595
+ const decisionWithId = Guard.attachCallId(decision, callId);
596
+ if (isV2 && decision.allowed && TERMINAL_CAPTURES.has(extra["capture"])) {
597
+ this.chain.registerPending(nid, callId);
598
+ }
599
+ exc.decision = decisionWithId;
600
+ }
601
+ else if (decision.allowed) {
602
+ for (const c of filled)
603
+ this.chain.uncountCall(nid, c.meterKey ?? "*");
604
+ }
605
+ throw exc;
606
+ }
607
+ decision = Guard.attachCallId(decision, callId);
608
+ // 5. register pending (allows only, and only for a capture that promises a terminal
609
+ // observation -- a PRE_HOOK_ONLY allow is never going to get a recordOutcome() call, by
610
+ // construction, so it must never sit in complete()'s pending set).
611
+ if (isV2 && decision.allowed && TERMINAL_CAPTURES.has(extra["capture"])) {
612
+ this.chain.registerPending(nid, callId);
613
+ }
614
+ // 6. fall through to strike-policy handling, then return.
334
615
  if (!decision.allowed &&
335
616
  this.strikes !== null &&
336
617
  this.strikes.enabled &&
337
- !this.chain.isRevoked(this.node.nodeId)) {
338
- const count = this.chain.recordStrike(this.strikes.key(this.node.nodeId, scope));
618
+ !this.chain.isRevoked(nid)) {
619
+ const count = this.chain.recordStrike(this.strikes.key(nid, scope));
339
620
  if (count >= this.strikes.n) {
340
- const revoked = this.chain.revoke(this.node.nodeId);
621
+ const revoked = this.chain.revoke(nid);
622
+ const killExtra = isV2 ? this.pendingAtKill(revoked) : {};
341
623
  this.append("kill", {
342
624
  chain_id: this.chainId,
343
- target: this.node.nodeId,
625
+ target: nid,
344
626
  reason: "strike_policy",
345
627
  scope,
346
628
  strikes: count,
347
629
  mode: this.strikes.mode,
348
630
  revoked,
631
+ ...killExtra,
349
632
  });
350
633
  }
351
634
  }
@@ -362,7 +645,8 @@ class Guard {
362
645
  }
363
646
  /**
364
647
  * A pure dry-run: identical policy evaluation to `check`, but it never throws
365
- * and — critically — writes NOTHING to the audit log.
648
+ * and — critically — writes NOTHING to the audit log. Never allocates a `callId`
649
+ * (there is nothing to bind an outcome to).
366
650
  */
367
651
  wouldAllow(scope, options = {}) {
368
652
  const ctx = { ...(options.context ?? {}) };
@@ -378,21 +662,153 @@ class Guard {
378
662
  *
379
663
  * `scope` defaults to `tool` (or "-"), because the published schema requires
380
664
  * a string scope on allow and deny events.
665
+ *
666
+ * On a `schemaVersion: 2` chain this also allocates and attaches a `callId` (same fail-closed
667
+ * CSPRNG handling as `check`) — every allow/deny entry carries one; a deny never expects an
668
+ * outcome.
381
669
  */
382
670
  recordDenial(reason, message = "", options = {}) {
383
671
  Guard.checkDisposition(options.disposition ?? null);
384
672
  const r = reason instanceof reasons_js_1.Reason ? reason : new reasons_js_1.Reason(String(reason), { message });
385
673
  const decision = reasons_js_1.Decision.deny(r, this.node.nodeId);
674
+ const isV2 = this.isV2;
386
675
  const tool = options.tool ?? null;
387
- this.logDecision(decision, options.scope ?? tool ?? "-", tool, { ...(options.context ?? {}) }, options.disposition ?? null);
388
- return decision;
676
+ let callId = null;
677
+ if (isV2) {
678
+ try {
679
+ callId = (0, node_crypto_1.randomBytes)(16).toString("hex");
680
+ }
681
+ catch {
682
+ return reasons_js_1.Decision.deny(r, this.node.nodeId); // fail-closed: nothing written
683
+ }
684
+ }
685
+ try {
686
+ this.logDecision(decision, options.scope ?? tool ?? "-", tool, { ...(options.context ?? {}) }, options.disposition ?? null, isV2 ? { call_id: callId } : undefined);
687
+ }
688
+ catch (exc) {
689
+ if (exc instanceof audit_js_1.CommittedAuditError) {
690
+ exc.decision = Guard.attachCallId(decision, callId);
691
+ }
692
+ throw exc;
693
+ }
694
+ return Guard.attachCallId(decision, callId);
695
+ }
696
+ /**
697
+ * The body-owning wrapper's report of how an `allow`ed call (identified by `callId`, from that
698
+ * `check` call's `Decision.callId`) ended. `schemaVersion: 2` chains only.
699
+ *
700
+ * `bodyState` is one of the `BodyState` constants. `errorCode` is required exactly when
701
+ * `bodyState === BodyState.RAISED` (a normalized exception class name, never a message) and
702
+ * forbidden otherwise. `durationMs` (observation start to observation end) is required.
703
+ * `invokedParams` is the corresponding JSON object the wrapper observed immediately before the
704
+ * actual invocation — hashed the same way as `check`'s `authorizedParams`; omit the option to
705
+ * opt out. `receipt` is unverified carriage, `{type, ref, digest}`.
706
+ *
707
+ * Exactly one outcome per `callId` is enforced here (throws `DuplicateOutcomeError`); a second
708
+ * outcome for the same callId is a caller bug, not a policy outcome. A callId that was never
709
+ * pending anywhere in this chain (bound to a deny, or foreign) is still recorded — this is a
710
+ * best-effort runtime cleanup, not a gate; the offline verifier is what flags
711
+ * `outcome_without_allow`/`cross_ref` from the ledger alone.
712
+ */
713
+ recordOutcome(callId, bodyState, options) {
714
+ if (!this.isV2) {
715
+ throw new Error("recordOutcome requires a schemaVersion: 2 chain (Guard.issue(..., {schemaVersion: 2}))");
716
+ }
717
+ if (!reasons_js_1.BODY_STATES.has(bodyState)) {
718
+ throw new Error(`unknown bodyState ${JSON.stringify(bodyState)}; expected one of ` +
719
+ `${Array.from(reasons_js_1.BODY_STATES).sort().join(", ")}`);
720
+ }
721
+ const errorCode = options.errorCode ?? null;
722
+ if (bodyState === reasons_js_1.BodyState.RAISED) {
723
+ if (typeof errorCode !== "string" || !errorCode) {
724
+ throw new Error("errorCode is required (a non-empty string) when bodyState === BodyState.RAISED");
725
+ }
726
+ }
727
+ else if (errorCode !== null) {
728
+ throw new Error("errorCode is only permitted when bodyState === BodyState.RAISED");
729
+ }
730
+ const durationMs = options.durationMs;
731
+ if (!Number.isInteger(durationMs) || durationMs < 0) {
732
+ throw new Error(`durationMs must be a non-negative integer; got ${JSON.stringify(durationMs)}`);
733
+ }
734
+ const receipt = options.receipt ?? null;
735
+ if (receipt !== null) {
736
+ const r = receipt;
737
+ const missing = ["type", "ref", "digest"].filter((k) => !(k in r));
738
+ if (missing.length > 0) {
739
+ throw new Error(`receipt is missing ${JSON.stringify(missing)}; expected type/ref/digest`);
740
+ }
741
+ for (const k of ["type", "ref"]) {
742
+ if (typeof r[k] !== "string" || !r[k]) {
743
+ throw new Error(`receipt[${JSON.stringify(k)}] must be a non-empty string`);
744
+ }
745
+ }
746
+ if (typeof r["digest"] !== "string" || !/^[0-9a-f]{64}$/.test(r["digest"])) {
747
+ throw new Error("receipt['digest'] must be a lowercase-hex SHA-256 digest (64 hex characters) — spec section 7");
748
+ }
749
+ }
750
+ // Exactly one outcome per callId, "enforced at append" (spec section 3): peek first, but
751
+ // only COMMIT the outcomed/pending state AFTER the append actually reaches its commit point
752
+ // — see below. A pre-commit failure here (e.g. a canonicalization failure while hashing this
753
+ // entry) must leave callId exactly as unresolved as before this call, so a corrected retry
754
+ // is still possible and `complete()` does not wrongly believe the call was accounted for.
755
+ if (this.chain.isOutcomed(callId)) {
756
+ throw new DuplicateOutcomeError(`callId ${JSON.stringify(callId)} already has a recorded outcome`);
757
+ }
758
+ const fields = {
759
+ chain_id: this.chainId,
760
+ node: this.node.nodeId,
761
+ call_id: callId,
762
+ body_state: bodyState,
763
+ duration_ms: durationMs,
764
+ };
765
+ if (errorCode !== null)
766
+ fields["error_code"] = errorCode;
767
+ const [ph, preason] = this.paramsCommitment(options.invokedParams);
768
+ if (ph !== null)
769
+ fields["invoked_params_hash"] = ph;
770
+ else if (preason !== null)
771
+ fields["params_hash_reason"] = preason;
772
+ if (receipt !== null)
773
+ fields["receipt"] = { ...receipt };
774
+ let entry;
775
+ try {
776
+ entry = this.append("outcome", fields);
777
+ }
778
+ catch (exc) {
779
+ if (exc instanceof audit_js_1.CommittedAuditError) {
780
+ // post-commit: the outcome DID reach the in-memory chain; it is now safe (and correct)
781
+ // to mark it done and drop it from pending before the persistence failure propagates.
782
+ this.chain.markOutcomed(callId);
783
+ this.chain.resolvePending(callId);
784
+ }
785
+ throw exc;
786
+ }
787
+ // success: commit the bookkeeping only now, never before.
788
+ this.chain.markOutcomed(callId);
789
+ this.chain.resolvePending(callId);
790
+ return entry;
389
791
  }
390
792
  // ---- chain controls ---------------------------------------------------
793
+ /**
794
+ * `{pending_at_kill: [...]}` — the still-open callIds across every node a kill revoked,
795
+ * snapshotted (NOT cleared: a late `recordOutcome` after this kill is still accepted — spec
796
+ * section 1). Only meaningful on v2 chains; call only when `isV2`.
797
+ */
798
+ pendingAtKill(revokedNodes) {
799
+ const pending = new Set();
800
+ for (const nid of revokedNodes) {
801
+ for (const c of this.chain.pendingFor(nid))
802
+ pending.add(c);
803
+ }
804
+ return { pending_at_kill: Array.from(pending).sort() };
805
+ }
391
806
  /** Cascade-revoke a node — by default this one — and its whole subtree. */
392
807
  revoke(nodeId) {
393
808
  const target = nodeId ?? this.node.nodeId;
394
809
  const revoked = this.chain.revoke(target);
395
- this.append("kill", { chain_id: this.chainId, target, revoked });
810
+ const extra = this.isV2 ? this.pendingAtKill(revoked) : {};
811
+ this.append("kill", { chain_id: this.chainId, target, revoked, ...extra });
396
812
  return revoked;
397
813
  }
398
814
  /**
@@ -404,11 +820,13 @@ class Guard {
404
820
  */
405
821
  revokeAgent(agentId) {
406
822
  const revoked = this.chain.revokeAgent(agentId);
823
+ const extra = this.isV2 ? this.pendingAtKill(revoked) : {};
407
824
  this.append("kill", {
408
825
  chain_id: this.chainId,
409
826
  target: this.node.nodeId,
410
827
  agent: agentId,
411
828
  revoked,
829
+ ...extra,
412
830
  });
413
831
  return revoked;
414
832
  }