@responsibleai/agent-hooks 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
@@ -63,8 +63,13 @@ export interface Harness {
63
63
  * register the interceptors and resolver, set the enforcement mode,
64
64
  * the vector's composition profile (§7.1), and its identity provider
65
65
  * (§10.1; vectors declare `"jcs-sha256"` or `null` — custom providers
66
- * are functions and not vector-expressible). */
67
- setup(scenario: Scenario, interceptors: Interceptor[], resolver: ApprovalResolver | null, mode: EnforcementMode, composition: CompositionConfig, identityProvider: 'jcs-sha256' | null): void;
66
+ * are functions and not vector-expressible). When
67
+ * `redactForApproval` is non-empty the harness MUST register a §9
68
+ * approval redactor that replaces each listed §5.2 path in the
69
+ * request context's target with the string `"[redacted]"`
70
+ * (write-back mirrored per §4.3), leaving unresolvable paths
71
+ * untouched. */
72
+ setup(scenario: Scenario, interceptors: Interceptor[], resolver: ApprovalResolver | null, mode: EnforcementMode, composition: CompositionConfig, identityProvider: 'jcs-sha256' | 'ctk-fault' | null, redactForApproval?: string[]): void;
68
73
  run(): Promise<RunRecord>;
69
74
  teardown(): void;
70
75
  }
@@ -14,7 +14,7 @@ export declare class ReferenceHarness implements Harness {
14
14
  private emitter;
15
15
  private builder;
16
16
  private toolLog;
17
- setup(scenario: Scenario, interceptors: Interceptor[], resolver: ApprovalResolver | null, mode: EnforcementMode, composition: CompositionConfig, identityProvider: 'jcs-sha256' | null): void;
17
+ setup(scenario: Scenario, interceptors: Interceptor[], resolver: ApprovalResolver | null, mode: EnforcementMode, composition: CompositionConfig, identityProvider: 'jcs-sha256' | 'ctk-fault' | null, redactForApproval?: string[]): void;
18
18
  run(): Promise<RunRecord>;
19
19
  teardown(): void;
20
20
  }
@@ -14,6 +14,7 @@ const node_crypto_1 = require("node:crypto");
14
14
  const index_1 = require("../index");
15
15
  const builder_1 = require("../builder");
16
16
  const emitter_1 = require("../emitter");
17
+ const native_1 = require("../native");
17
18
  class ReferenceHarness {
18
19
  name = "reference-agent";
19
20
  capabilities = new Set(["model_calls", "tool_calls"]);
@@ -21,12 +22,33 @@ class ReferenceHarness {
21
22
  emitter;
22
23
  builder;
23
24
  toolLog = [];
24
- setup(scenario, interceptors, resolver, mode, composition, identityProvider) {
25
+ setup(scenario, interceptors, resolver, mode, composition, identityProvider, redactForApproval = []) {
25
26
  this.scenario = scenario;
26
27
  this.toolLog = [];
27
28
  const em = new emitter_1.InterceptionEmitter(mode, resolver);
28
29
  em.setComposition(composition);
29
- em.setIdentityProvider(identityProvider);
30
+ if (redactForApproval.length > 0) {
31
+ // §9 redaction seam, CTK convention: each listed path is replaced
32
+ // with "[redacted]" via the §5.2/§4.3 transform machinery;
33
+ // unresolvable paths are left untouched.
34
+ em.setApprovalRedactor((ctx) => {
35
+ let out = JSON.stringify(ctx);
36
+ for (const path of redactForApproval) {
37
+ try {
38
+ out = native_1.native.applyTransformCtx(out, path, '"[redacted]"');
39
+ }
40
+ catch {
41
+ /* unresolvable at this point — skip */
42
+ }
43
+ }
44
+ return JSON.parse(out);
45
+ });
46
+ }
47
+ // §13.2: "ctk-fault" is a custom provider that throws, pinning the
48
+ // §10.1 provider-failure rule (deny context_invalid pre-dispatch).
49
+ em.setIdentityProvider(identityProvider === 'ctk-fault'
50
+ ? { name: 'ctk-fault', fn: () => { throw new Error('ctk scripted provider fault'); } }
51
+ : identityProvider);
30
52
  for (const i of interceptors)
31
53
  em.register(i);
32
54
  this.emitter = em;
@@ -8,12 +8,6 @@
8
8
  */
9
9
  import { JsonValue } from "../index";
10
10
  import type { Harness } from "./index";
11
- /** TODO(stage-4): vectors still authored in the pre-P-003 five-verdict
12
- * wire vocabulary (`warn`, `escalate`, `approval_resolver_missing`).
13
- * Stage 4 rewrites them to the three-verdict shapes (§5.1); until then
14
- * their scripted verdicts fail the §5 gate by design (fail closed) and
15
- * no longer exercise the seam/warning semantics they were written for,
16
- * so they are skipped here. */
17
11
  export interface VectorResult {
18
12
  id: string;
19
13
  title: string;
@@ -38,7 +38,18 @@ class ScriptedInterceptor {
38
38
  intercept(ctx) {
39
39
  const w = JSON.parse(native_1.native.ctkScriptedIntercept(this.rulesJson, JSON.stringify(ctx)));
40
40
  if (w !== null && typeof w === "object" && "__ctk_fault__" in w) {
41
- // NOW-10 fault injection: exercise §6.3 interceptor_failed.
41
+ if (w.__ctk_fault__ === "mutate") {
42
+ // §7 isolation fault (TM-05): tamper with the received context
43
+ // in-place; the emitter's copy isolation must keep enforcement,
44
+ // identity, and siblings unaffected.
45
+ ctx.target = "TAMPERED";
46
+ const tc = ctx.tool_call;
47
+ if (tc && typeof tc === "object") {
48
+ tc.args = { tampered: true };
49
+ }
50
+ return { decision: "allow", reason: "ctk:mutated" };
51
+ }
52
+ // Fault injection: exercise §6.3 interceptor_failed.
42
53
  throw new Error("ctk scripted fault: raise");
43
54
  }
44
55
  return w;
@@ -63,7 +74,7 @@ class ScriptedResolver {
63
74
  const requestIdentity = req.context_identity ?? "";
64
75
  const r = JSON.parse(native_1.native.ctkScriptedResolve(this.rulesJson, JSON.stringify(req.context), requestIdentity));
65
76
  if (r !== null && typeof r === "object" && "__ctk_fault__" in r) {
66
- // NOW-10 fault injection: exercise §9 approval_resolver_failed.
77
+ // Fault injection: exercise §9 approval_resolver_failed.
67
78
  throw new Error("ctk scripted fault: raise");
68
79
  }
69
80
  if (r.context_identity === "" && req.context_identity === null) {
@@ -115,8 +126,11 @@ async function runVector(harness, vector) {
115
126
  // absent means the pre-P-003 default (`sequential/first_deny, stop`).
116
127
  const composition = v.composition ?? index_1.Composition.default();
117
128
  // §10.1: absent → the default provider; explicit null → unbound.
118
- const identityProvider = 'identity_provider' in v ? v.identity_provider : 'jcs-sha256';
119
- harness.setup(v.scenario, interceptors, resolver, mode, composition, identityProvider);
129
+ const identityProvider = 'identity_provider' in v
130
+ ? v.identity_provider
131
+ : 'jcs-sha256';
132
+ const redactForApproval = v.redact_for_approval ?? [];
133
+ harness.setup(v.scenario, interceptors, resolver, mode, composition, identityProvider, redactForApproval);
120
134
  let rr;
121
135
  try {
122
136
  rr = await harness.run();
package/dist/emitter.d.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * on the event loop; sequence assignment and record append are atomic on
28
28
  * a single JS thread. Sharing one emitter across workers is unsupported.
29
29
  */
30
- import { AgentContext, ApprovalResolver, CompositionConfig, EnforcementMode, IdentityProvider, InterceptionRecord, Interceptor } from "./index";
30
+ import { AgentContext, ApprovalResolver, CompositionConfig, EnforcementMode, IdentityProvider, EmitOutcome, InterceptionRecord, Interceptor } from "./index";
31
31
  /** §7 RECOMMENDED interceptor/resolver timeout (milliseconds). */
32
32
  export declare const DEFAULT_TIMEOUT_MS = 5000;
33
33
  export declare class InterceptionEmitter {
@@ -35,9 +35,14 @@ export declare class InterceptionEmitter {
35
35
  private readonly resolver;
36
36
  private readonly timeoutMs;
37
37
  private readonly interceptors;
38
- private readonly _records;
38
+ private _records;
39
39
  private composition;
40
40
  private identity;
41
+ private readonly names;
42
+ private approvalRedactor;
43
+ private recordSink;
44
+ private maxRecords;
45
+ private _recordsDropped;
41
46
  /**
42
47
  * `timeoutMs` bounds each interceptor `intercept()` and resolver
43
48
  * `resolve()` call (§7, RECOMMENDED default 5000 ms); breach fails
@@ -50,14 +55,57 @@ export declare class InterceptionEmitter {
50
55
  /** Race `fn`'s result against the §7 timeout. */
51
56
  private withTimeout;
52
57
  get records(): readonly InterceptionRecord[];
53
- register(interceptor: Interceptor): this;
54
- /** Declare the composition profile for subsequent emissions (§7.1). */
58
+ /** Register an interceptor, optionally with a host-chosen
59
+ * payload-free `name` recorded on `verdicts[].name` (§10.3). */
60
+ register(interceptor: Interceptor, name?: string): this;
61
+ /**
62
+ * Declare the composition profile for subsequent emissions (§7.1).
63
+ * The profile and knob values are validated against the closed §7.2
64
+ * sets at call time (the compile-time types do not protect plain-JS
65
+ * callers): an undeclared value throws `RangeError` so no emission
66
+ * can start under semantics the operator did not declare.
67
+ *
68
+ * The default (`sequential/first_deny`, `on_approval: stop`) is the
69
+ * configuration §14 warns about: after an approval lifts a liftable
70
+ * deny, interceptors registered after the escalating one never run
71
+ * for that emission (`fold_truncated` on the record). Register
72
+ * must-run controls first, or use `sequential/run_all` / a parallel
73
+ * profile. See docs/PRODUCTION.md.
74
+ */
55
75
  setComposition(composition: CompositionConfig): this;
56
- /** Declare the identity provider (§10.1). Default `"jcs-sha256"`. */
76
+ /** Declare the identity provider (§10.1). Default `"jcs-sha256"`.
77
+ * §10.1 name rules are enforced: a custom provider name must match
78
+ * `^[a-z][a-z0-9_-]*$` and must not begin with `jcs` (reserved so a
79
+ * custom function can never claim golden-vector semantics). */
57
80
  setIdentityProvider(provider: IdentityProvider): this;
81
+ /** Register the §9/§14 approval redactor: a pure function producing
82
+ * the context to place in every ApprovalRequest. The §9 identity is
83
+ * computed over the redacted context (binding the approval to what
84
+ * the approver saw); the record's identities are unaffected. A
85
+ * redactor that throws fails the consultation closed as
86
+ * `host_error:approval_resolver_failed`. */
87
+ setApprovalRedactor(redactor: (ctx: AgentContext) => AgentContext): this;
88
+ /** Register a per-emission record callback (§10.3), invoked
89
+ * synchronously after every emission before buffering; a sink
90
+ * exception is swallowed (audit delivery is the host's liveness
91
+ * concern, not the control plane's). */
92
+ setRecordSink(sink: (record: InterceptionRecord) => void): this;
93
+ /** Bound the in-memory record buffer: when full, the OLDEST record
94
+ * is dropped and {@link recordsDropped} increments. Unbounded by
95
+ * default. */
96
+ setMaxRecords(max: number): this;
97
+ /** Records evicted by the {@link setMaxRecords} bound. */
98
+ get recordsDropped(): number;
99
+ /** Drain the in-memory record buffer (retention stays bounded on
100
+ * long-running sessions). */
101
+ takeRecords(): InterceptionRecord[];
58
102
  /** Run the emission and **throw** {@link InterceptionBlocked} if the
59
- * guarded action must not proceed (§6). Primary entry point. */
60
- emit(ctx: AgentContext): Promise<InterceptionRecord>;
103
+ * guarded action must not proceed (§6). Primary entry point.
104
+ *
105
+ * Returns the record plus the **effective** (post-composition)
106
+ * target the guarded action MUST consume (§4.3) — a reference
107
+ * captured before `emit` may predate a transform. */
108
+ emit(ctx: AgentContext): Promise<EmitOutcome>;
61
109
  /** Run the emission and return the record without throwing. The
62
110
  * caller MUST inspect `proceeds` and halt the guarded action itself;
63
111
  * prefer {@link emit}. */
@@ -102,6 +150,9 @@ export declare class InterceptionEmitter {
102
150
  /** The declared provider's output for `ctx` (§10.1); `null` iff the
103
151
  * provider is `null`. Throws {@link AgentHooksCoreError} iff the
104
152
  * provider rejected the context (§10.2 fail-closed value domain). */
153
+ /** Custom-provider identity, or null when the provider fails (the
154
+ * emission has already been decided at this point). */
155
+ private tryCustomIdentity;
105
156
  private identityOf;
106
157
  /** Consult the approval seam for a liftable deny (§9), when the
107
158
  * profile conditions allow it: `enforce` mode, not `agent_shutdown`,
package/dist/emitter.js CHANGED
@@ -117,6 +117,11 @@ class InterceptionEmitter {
117
117
  _records = [];
118
118
  composition = index_1.Composition.default();
119
119
  identity = index_1.JCS_SHA256;
120
+ names = [];
121
+ approvalRedactor = null;
122
+ recordSink = null;
123
+ maxRecords = null;
124
+ _recordsDropped = 0;
120
125
  /**
121
126
  * `timeoutMs` bounds each interceptor `intercept()` and resolver
122
127
  * `resolve()` call (§7, RECOMMENDED default 5000 ms); breach fails
@@ -151,27 +156,105 @@ class InterceptionEmitter {
151
156
  get records() {
152
157
  return this._records;
153
158
  }
154
- register(interceptor) {
159
+ /** Register an interceptor, optionally with a host-chosen
160
+ * payload-free `name` recorded on `verdicts[].name` (§10.3). */
161
+ register(interceptor, name) {
155
162
  this.interceptors.push(interceptor);
163
+ this.names.push(name ?? null);
156
164
  return this;
157
165
  }
158
- /** Declare the composition profile for subsequent emissions (§7.1). */
166
+ /**
167
+ * Declare the composition profile for subsequent emissions (§7.1).
168
+ * The profile and knob values are validated against the closed §7.2
169
+ * sets at call time (the compile-time types do not protect plain-JS
170
+ * callers): an undeclared value throws `RangeError` so no emission
171
+ * can start under semantics the operator did not declare.
172
+ *
173
+ * The default (`sequential/first_deny`, `on_approval: stop`) is the
174
+ * configuration §14 warns about: after an approval lifts a liftable
175
+ * deny, interceptors registered after the escalating one never run
176
+ * for that emission (`fold_truncated` on the record). Register
177
+ * must-run controls first, or use `sequential/run_all` / a parallel
178
+ * profile. See docs/PRODUCTION.md.
179
+ */
159
180
  setComposition(composition) {
181
+ const profiles = Object.values(index_1.CompositionProfile);
182
+ if (!profiles.includes(composition.profile)) {
183
+ throw new RangeError(`unknown composition profile ${JSON.stringify(composition.profile)}: the profile set is closed (§7.2)`);
184
+ }
185
+ const knobs = [
186
+ ["on_approval", composition.on_approval, ["stop", "resume"]],
187
+ ["on_disagreement", composition.on_disagreement, ["deny", "approval"]],
188
+ ["on_transform_conflict", composition.on_transform_conflict, ["deny", "approval"]],
189
+ ];
190
+ for (const [name, value, allowed] of knobs) {
191
+ if (value !== undefined && !allowed.includes(value)) {
192
+ throw new RangeError(`unknown ${name} value ${JSON.stringify(value)}: ${allowed.map((a) => `"${a}"`).join(" | ")} (§7.4–§7.5)`);
193
+ }
194
+ }
160
195
  this.composition = composition;
161
196
  return this;
162
197
  }
163
- /** Declare the identity provider (§10.1). Default `"jcs-sha256"`. */
198
+ /** Declare the identity provider (§10.1). Default `"jcs-sha256"`.
199
+ * §10.1 name rules are enforced: a custom provider name must match
200
+ * `^[a-z][a-z0-9_-]*$` and must not begin with `jcs` (reserved so a
201
+ * custom function can never claim golden-vector semantics). */
164
202
  setIdentityProvider(provider) {
203
+ if (provider !== null && provider !== index_1.JCS_SHA256) {
204
+ if (!/^[a-z][a-z0-9_-]*$/.test(provider.name) || provider.name.startsWith("jcs")) {
205
+ throw new RangeError("identity provider name must match ^[a-z][a-z0-9_-]*$ and must not begin with 'jcs' (§10.1)");
206
+ }
207
+ }
165
208
  this.identity = provider;
166
209
  return this;
167
210
  }
211
+ /** Register the §9/§14 approval redactor: a pure function producing
212
+ * the context to place in every ApprovalRequest. The §9 identity is
213
+ * computed over the redacted context (binding the approval to what
214
+ * the approver saw); the record's identities are unaffected. A
215
+ * redactor that throws fails the consultation closed as
216
+ * `host_error:approval_resolver_failed`. */
217
+ setApprovalRedactor(redactor) {
218
+ this.approvalRedactor = redactor;
219
+ return this;
220
+ }
221
+ /** Register a per-emission record callback (§10.3), invoked
222
+ * synchronously after every emission before buffering; a sink
223
+ * exception is swallowed (audit delivery is the host's liveness
224
+ * concern, not the control plane's). */
225
+ setRecordSink(sink) {
226
+ this.recordSink = sink;
227
+ return this;
228
+ }
229
+ /** Bound the in-memory record buffer: when full, the OLDEST record
230
+ * is dropped and {@link recordsDropped} increments. Unbounded by
231
+ * default. */
232
+ setMaxRecords(max) {
233
+ this.maxRecords = max;
234
+ return this;
235
+ }
236
+ /** Records evicted by the {@link setMaxRecords} bound. */
237
+ get recordsDropped() {
238
+ return this._recordsDropped;
239
+ }
240
+ /** Drain the in-memory record buffer (retention stays bounded on
241
+ * long-running sessions). */
242
+ takeRecords() {
243
+ const out = this._records;
244
+ this._records = [];
245
+ return out;
246
+ }
168
247
  /** Run the emission and **throw** {@link InterceptionBlocked} if the
169
- * guarded action must not proceed (§6). Primary entry point. */
248
+ * guarded action must not proceed (§6). Primary entry point.
249
+ *
250
+ * Returns the record plus the **effective** (post-composition)
251
+ * target the guarded action MUST consume (§4.3) — a reference
252
+ * captured before `emit` may predate a transform. */
170
253
  async emit(ctx) {
171
254
  const record = await this.emitUnchecked(ctx);
172
255
  if (!(0, index_1.proceeds)(record))
173
256
  throw new index_1.InterceptionBlocked(record);
174
- return record;
257
+ return { record, target: ctx["target"] };
175
258
  }
176
259
  /** Run the emission and return the record without throwing. The
177
260
  * caller MUST inspect `proceeds` and halt the guarded action itself;
@@ -187,15 +270,17 @@ class InterceptionEmitter {
187
270
  outcome = synthesized(index_1.HostError.ContextInvalid, (0, index_1.nonFiniteDetail)(hit));
188
271
  }
189
272
  else {
190
- // §10.3: input identity binds to the context BEFORE dispatch, so
191
- // neither interceptor mutation nor fold-through can retroactively
192
- // alter what the record claims was evaluated.
273
+ // §4/§6.3: an invalid envelope is denied before any interceptor
274
+ // or identity provider sees it; §10.3: input identity binds to
275
+ // the context BEFORE dispatch.
193
276
  try {
277
+ native_1.native.validateEnvelope(JSON.stringify(ctx));
194
278
  inputIdentity = this.identityOf(ctx);
195
279
  }
196
280
  catch (e) {
197
- // §10.2: the default provider rejected the value domain. Fail
198
- // closed before any interceptor runs.
281
+ // §10.1/§10.2: envelope invalid, value domain rejected, or the
282
+ // provider itself failed. Fail closed before any interceptor
283
+ // runs.
199
284
  outcome = synthesized(...codeAndDetail(e));
200
285
  }
201
286
  }
@@ -208,14 +293,20 @@ class InterceptionEmitter {
208
293
  identity_provider: providerName,
209
294
  enforced_identity:
210
295
  // Custom providers only; the core computes jcs-sha256 itself.
296
+ // A provider that fails here yields honest absence (§10.1) —
297
+ // the pre-dispatch failure path already denies.
211
298
  !identityFailed && this.identity !== null && this.identity !== index_1.JCS_SHA256
212
- ? this.identity.fn(ctx)
299
+ ? this.tryCustomIdentity(ctx)
213
300
  : null,
214
301
  decided_by: outcome.decidedBy,
215
302
  composition: this.composition,
216
- verdicts: outcome.verdicts,
303
+ verdicts: outcome.verdicts.map((v) => ({
304
+ ...v,
305
+ ...(this.names[v.index] != null ? { name: this.names[v.index] } : {}),
306
+ })),
217
307
  fold_truncated: outcome.foldTruncated,
218
308
  resolved_by: outcome.resolvedBy,
309
+ interceptors_registered: this.interceptors.length,
219
310
  };
220
311
  let record;
221
312
  if (identityFailed) {
@@ -229,6 +320,21 @@ class InterceptionEmitter {
229
320
  else {
230
321
  record = (0, index_1.finalize)(ctx, outcome.combined, this.mode, meta);
231
322
  }
323
+ if (this.recordSink) {
324
+ // Audit delivery must not take down the control plane (§10.3).
325
+ try {
326
+ this.recordSink(record);
327
+ }
328
+ catch {
329
+ /* swallowed by design */
330
+ }
331
+ }
332
+ if (this.maxRecords !== null) {
333
+ while (this._records.length >= Math.max(this.maxRecords, 1)) {
334
+ this._records.shift();
335
+ this._recordsDropped += 1;
336
+ }
337
+ }
232
338
  this._records.push(record);
233
339
  return record;
234
340
  }
@@ -247,8 +353,14 @@ class InterceptionEmitter {
247
353
  return this.dispatchFirstDeny(ctx);
248
354
  case index_1.CompositionProfile.SequentialRunAll:
249
355
  return this.dispatchRunAll(ctx);
250
- default:
356
+ case index_1.CompositionProfile.ParallelStrictest:
357
+ case index_1.CompositionProfile.ParallelUnanimous:
251
358
  return this.dispatchParallel(ctx);
359
+ default:
360
+ // Unreachable through the public API: setComposition validates
361
+ // against the closed §7.2 set. Fail closed rather than silently
362
+ // dispatch under undeclared semantics.
363
+ return synthesized(index_1.HostError.ContextInvalid, "undeclared composition profile (§7.2)");
252
364
  }
253
365
  }
254
366
  /** Invoke one interceptor on its own copy of `ctx` (§7) and pass the
@@ -328,13 +440,14 @@ class InterceptionEmitter {
328
440
  };
329
441
  }
330
442
  if (!c.permitted) {
331
- // Reject / unresolved / echo violation: a deny stands (§9).
443
+ // Reject / unresolved / echo violation: a deny stands (§9);
444
+ // the consultation is still recorded (§10.3 resolved_by).
332
445
  return {
333
446
  combined: withUnions(c.verdict, pool),
334
447
  decidedBy: isHostSynthesized(c.verdict) ? null : i,
335
448
  verdicts: summaries(perInterceptor),
336
449
  foldTruncated: truncated(i),
337
- resolvedBy,
450
+ resolvedBy: "rejection",
338
451
  };
339
452
  }
340
453
  resolvedBy = "approval";
@@ -483,6 +596,8 @@ class InterceptionEmitter {
483
596
  combined = (0, index_1.permits)(sub.decision) ? withUnions(sub, [...all, sub]) : sub;
484
597
  }
485
598
  else {
599
+ // §10.3: consultation without a permit substitution.
600
+ resolvedBy = "rejection";
486
601
  combined = withUnions(c.verdict, all);
487
602
  if (isHostSynthesized(c.verdict))
488
603
  decidedBy = null;
@@ -522,6 +637,16 @@ class InterceptionEmitter {
522
637
  /** The declared provider's output for `ctx` (§10.1); `null` iff the
523
638
  * provider is `null`. Throws {@link AgentHooksCoreError} iff the
524
639
  * provider rejected the context (§10.2 fail-closed value domain). */
640
+ /** Custom-provider identity, or null when the provider fails (the
641
+ * emission has already been decided at this point). */
642
+ tryCustomIdentity(ctx) {
643
+ try {
644
+ return this.identityOf(ctx);
645
+ }
646
+ catch {
647
+ return null;
648
+ }
649
+ }
525
650
  identityOf(ctx) {
526
651
  if (this.identity === null)
527
652
  return null;
@@ -531,7 +656,9 @@ class InterceptionEmitter {
531
656
  return this.identity.fn(ctx);
532
657
  }
533
658
  catch (e) {
534
- throw new native_1.AgentHooksCoreError(index_1.HostError.ContextInvalid, `identity provider ${this.identity.name} raised: ${e?.message ?? e}`);
659
+ // §14/TM-09: exception *type* only — a provider error message can
660
+ // embed the context it was hashing.
661
+ throw new native_1.AgentHooksCoreError(index_1.HostError.ContextInvalid, `identity provider failed: ${e?.constructor?.name ?? "Error"} (see spec §10.1)`);
535
662
  }
536
663
  }
537
664
  /** Consult the approval seam for a liftable deny (§9), when the
@@ -552,11 +679,23 @@ class InterceptionEmitter {
552
679
  verdict: (0, index_1.hostErrorVerdict)(err, detail),
553
680
  permitted: false,
554
681
  });
682
+ // §9/§14: the host's approval redactor minimizes the context
683
+ // egressing through the seam; a throwing redactor fails closed.
684
+ let presented = ctx;
685
+ if (this.approvalRedactor) {
686
+ try {
687
+ presented = this.approvalRedactor(ctx);
688
+ }
689
+ catch (e) {
690
+ return fail(index_1.HostError.ApprovalResolverFailed, e?.constructor?.name ?? "Error");
691
+ }
692
+ }
555
693
  // §9: identity of the context as presented to the resolver —
556
- // consultation time, after any transforms that folded earlier.
694
+ // consultation time, after any transforms that folded earlier and
695
+ // after any redaction.
557
696
  let identity;
558
697
  try {
559
- identity = this.identityOf(ctx);
698
+ identity = this.identityOf(presented);
560
699
  }
561
700
  catch (e) {
562
701
  const [code, detail] = codeAndDetail(e);
@@ -568,7 +707,7 @@ class InterceptionEmitter {
568
707
  context_identity: identity,
569
708
  interception_point: ctx.interception_point,
570
709
  verdict,
571
- context: ctx,
710
+ context: presented,
572
711
  }));
573
712
  }
574
713
  catch (e) {
package/dist/index.d.ts CHANGED
@@ -98,6 +98,9 @@ export declare const Verdict: Readonly<{
98
98
  /** What earlier drafts called `warn`: an `allow` carrying one
99
99
  * warning (§5.1). */
100
100
  warn(reason?: string, message?: string): Verdict;
101
+ /** A plain, final deny: no `approval` block, so the approval seam
102
+ * cannot lift it (§5.1). */
103
+ deny(reason?: string, message?: string): Verdict;
101
104
  /** What earlier drafts called `escalate`: a liftable deny — denied
102
105
  * as-is unless the approval seam lifts it (§5.1, §9). */
103
106
  escalate(reason?: string, message?: string): Verdict;
@@ -186,6 +189,8 @@ export interface VerdictSummary {
186
189
  index: number;
187
190
  decision: Decision;
188
191
  reason?: string | null;
192
+ /** Host-chosen payload-free registration identifier (§10.3). */
193
+ name?: string;
189
194
  }
190
195
  /** Host-side record of one emission (§10.3).
191
196
  *
@@ -211,6 +216,15 @@ export interface InterceptionRecord {
211
216
  session_id: string;
212
217
  /** `ctx.sequence` — total order within the session (§12.2.3). */
213
218
  sequence: number;
219
+ /** RFC 3339 instant copied from `ctx.timestamp` (§10.3); absent when
220
+ * the context lacked the field. */
221
+ timestamp?: string;
222
+ /** W3C Trace Context correlation echoed from the context's optional
223
+ * `trace` block (§4.5); absent when the context carried none. */
224
+ trace?: {
225
+ trace_id?: string;
226
+ span_id?: string;
227
+ };
214
228
  /** Registration index of the interceptor whose verdict won the
215
229
  * aggregation or whose liftable deny was consulted (§7.6); `null`
216
230
  * for a pure-allow combination or a host-synthesized verdict. */
@@ -221,11 +235,15 @@ export interface InterceptionRecord {
221
235
  * (`sequential/run_all`, `parallel/*`). */
222
236
  verdicts?: VerdictSummary[];
223
237
  /** `true` iff one or more registered interceptors were never invoked
224
- * in this emission. Defined only for `sequential/first_deny` (§7.4). */
238
+ * in this emission (short-circuit, approval-stop, or a failed
239
+ * fold-transform). Defined for the sequential profiles (§7.4). */
225
240
  fold_truncated?: boolean;
226
- /** `"approval"` iff an approval resolution substituted for a verdict
227
- * in this emission (§7.6). */
228
- resolved_by?: "approval" | null;
241
+ /** Consultation outcome (§7.6, §10.3): `"approval"` iff a permit
242
+ * resolution substituted; `"rejection"` iff consulted without a
243
+ * lift; absent iff never consulted. */
244
+ resolved_by?: "approval" | "rejection" | null;
245
+ /** Interceptors registered at emission time (§10.3). */
246
+ interceptors_registered: number;
229
247
  }
230
248
  /** Whether the guarded action executes (§6, §8). */
231
249
  export declare function proceeds(r: InterceptionRecord): boolean;
@@ -299,10 +317,12 @@ export interface FinalizeMeta {
299
317
  composition: CompositionConfig;
300
318
  /** Per-interceptor summaries (multi-verdict profiles, §10.3). */
301
319
  verdicts?: VerdictSummary[] | null;
302
- /** `sequential/first_deny` only (§7.4). */
320
+ /** Sequential profiles (§7.4). */
303
321
  fold_truncated?: boolean | null;
304
- /** `"approval"` iff a resolution substituted for a verdict (§7.6). */
305
- resolved_by?: "approval" | null;
322
+ /** Consultation outcome: `"approval"` / `"rejection"` / none (§7.6). */
323
+ resolved_by?: "approval" | "rejection" | null;
324
+ /** Interceptors registered at emission time (§10.3). */
325
+ interceptors_registered?: number;
306
326
  }
307
327
  /** §10.3: build the `InterceptionRecord` for one completed emission.
308
328
  * `meta.input_identity` MUST have been computed before interceptor
@@ -322,6 +342,13 @@ export declare function composeAggregate(composition: CompositionConfig, verdict
322
342
  export { AgentContextBuilder } from "./builder";
323
343
  export { InterceptionEmitter } from "./emitter";
324
344
  /** Raised by a host when a verdict blocks the guarded action (§6). */
345
+ /** Returned by `InterceptionEmitter.emit` on a proceeding emission:
346
+ * the record plus the **effective** (post-composition) target the
347
+ * guarded action MUST consume (§4.3). */
348
+ export interface EmitOutcome {
349
+ record: InterceptionRecord;
350
+ target: unknown;
351
+ }
325
352
  export declare class InterceptionBlocked extends Error {
326
353
  readonly result: InterceptionRecord;
327
354
  constructor(result: InterceptionRecord);
package/dist/index.js CHANGED
@@ -90,6 +90,11 @@ exports.Verdict = Object.freeze({
90
90
  warn(reason, message) {
91
91
  return { decision: exports.Decision.Allow, warnings: [{ reason, message }] };
92
92
  },
93
+ /** A plain, final deny: no `approval` block, so the approval seam
94
+ * cannot lift it (§5.1). */
95
+ deny(reason, message) {
96
+ return { decision: exports.Decision.Deny, reason, message };
97
+ },
93
98
  /** What earlier drafts called `escalate`: a liftable deny — denied
94
99
  * as-is unless the approval seam lifts it (§5.1, §9). */
95
100
  escalate(reason, message) {
@@ -249,6 +254,7 @@ function finalize(ctx, verdict, mode, meta) {
249
254
  verdicts: meta.verdicts ?? null,
250
255
  fold_truncated: meta.fold_truncated ?? null,
251
256
  resolved_by: meta.resolved_by ?? null,
257
+ interceptors_registered: meta.interceptors_registered ?? 0,
252
258
  })));
253
259
  }
254
260
  /** §7.3/§7.5 aggregation for the multi-verdict profiles: severity-max
@@ -261,7 +267,6 @@ var builder_1 = require("./builder");
261
267
  Object.defineProperty(exports, "AgentContextBuilder", { enumerable: true, get: function () { return builder_1.AgentContextBuilder; } });
262
268
  var emitter_1 = require("./emitter");
263
269
  Object.defineProperty(exports, "InterceptionEmitter", { enumerable: true, get: function () { return emitter_1.InterceptionEmitter; } });
264
- /** Raised by a host when a verdict blocks the guarded action (§6). */
265
270
  class InterceptionBlocked extends Error {
266
271
  result;
267
272
  constructor(result) {
package/dist/native.d.ts CHANGED
@@ -16,6 +16,7 @@ export declare const native: {
16
16
  canonicalJson: (valueJson: string) => string;
17
17
  contextIdentity: (ctxJson: string) => string;
18
18
  validateVerdict: (verdictJson: string) => string;
19
+ validateEnvelope: (ctxJson: string) => string;
19
20
  applyTransform: (targetJson: string, path: string, valueJson: string) => string;
20
21
  applyTransformCtx: (ctxJson: string, path: string, valueJson: string) => string;
21
22
  validateTransformCtx: (ctxJson: string, path: string, valueJson: string) => string;
package/dist/native.js CHANGED
@@ -44,6 +44,7 @@ exports.native = {
44
44
  canonicalJson: wrap(binding.canonicalJson),
45
45
  contextIdentity: wrap(binding.contextIdentity),
46
46
  validateVerdict: wrap(binding.validateVerdict),
47
+ validateEnvelope: wrap(binding.validateEnvelope),
47
48
  applyTransform: wrap(binding.applyTransform),
48
49
  applyTransformCtx: wrap(binding.applyTransformCtx),
49
50
  validateTransformCtx: wrap(binding.validateTransformCtx),