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