@responsibleai/agent-hooks 0.1.0-beta.1 → 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/emitter.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
- // Copyright (c) Microsoft Corporation.
3
- // Licensed under the MIT License.
2
+ // Copyright (c) Microsoft. All rights reserved.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.InterceptionEmitter = exports.EmitterSealed = exports.DEFAULT_TIMEOUT_MS = void 0;
4
5
  /**
5
6
  * Host-side emitter: dispatch context → interceptors → composition →
6
7
  * combined verdict → record (§6–§10).
@@ -20,8 +21,15 @@
20
21
  * serial dispatch over isolated snapshots — §7.2: parallel names
21
22
  * isolation semantics, not scheduling.
22
23
  *
23
- * Fail-closed defaults: an `enforce`-mode emission with zero registered
24
- * interceptors yields `deny host_error:no_interceptor` (§7), and
24
+ * An emitter can also be built from a host declaration document (§7.7)
25
+ * through {@link InterceptionEmitter.fromDeclaration} and the path, JSON
26
+ * and value forms. The document is validated and resolved by the Rust
27
+ * core; this module runs the host's kind resolvers (step 11 of §7.7.6),
28
+ * seals the emitter (§7.7.7) and stamps the contract version on every
29
+ * record (§7.7.8).
30
+ *
31
+ * Fail-closed defaults: an `enforce`-mode emission with zero interceptors
32
+ * bound at the point yields `deny host_error:no_interceptor` (§7), and
25
33
  * {@link InterceptionEmitter.emit} **throws** {@link InterceptionBlocked}
26
34
  * on any block — the ignorable-result variant is the explicitly named
27
35
  * {@link InterceptionEmitter.emitUnchecked}.
@@ -30,9 +38,8 @@
30
38
  * on the event loop; sequence assignment and record append are atomic on
31
39
  * a single JS thread. Sharing one emitter across workers is unsupported.
32
40
  */
33
- Object.defineProperty(exports, "__esModule", { value: true });
34
- exports.InterceptionEmitter = exports.DEFAULT_TIMEOUT_MS = void 0;
35
41
  const index_1 = require("./index");
42
+ const declaration_1 = require("./declaration");
36
43
  const native_1 = require("./native");
37
44
  /** §7 RECOMMENDED interceptor/resolver timeout (milliseconds). */
38
45
  exports.DEFAULT_TIMEOUT_MS = 5000;
@@ -43,6 +50,24 @@ class InterceptTimeout extends Error {
43
50
  this.name = "InterceptTimeout";
44
51
  }
45
52
  }
53
+ /** Thrown when a setter or `register` is called on an emitter built
54
+ * from a host declaration (§7.7.7): the declaration must stay what ran. */
55
+ /** Recursively freeze a JSON-shaped value in place and return it. */
56
+ function deepFreeze(value) {
57
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
58
+ Object.freeze(value);
59
+ for (const v of Object.values(value))
60
+ deepFreeze(v);
61
+ }
62
+ return value;
63
+ }
64
+ class EmitterSealed extends Error {
65
+ constructor(what) {
66
+ super(`${what}: this emitter was built from a host declaration and is sealed (see spec §7.7.7)`);
67
+ this.name = "EmitterSealed";
68
+ }
69
+ }
70
+ exports.EmitterSealed = EmitterSealed;
46
71
  /** Whether a verdict was synthesized by the host (§11) rather than
47
72
  * returned by an interceptor or resolver. */
48
73
  function isHostSynthesized(v) {
@@ -109,19 +134,32 @@ function synthesized(err, detail) {
109
134
  };
110
135
  }
111
136
  const NOT_CONSULTED = { consulted: false };
137
+ /** Whether `v` looks like an interceptor (§7): an object with an
138
+ * `intercept` method. */
139
+ function isInterceptor(v) {
140
+ return (v !== null &&
141
+ (typeof v === "object" || typeof v === "function") &&
142
+ typeof v.intercept === "function");
143
+ }
112
144
  class InterceptionEmitter {
113
- mode;
114
- resolver;
115
- timeoutMs;
116
145
  interceptors = [];
117
146
  _records = [];
118
147
  composition = index_1.Composition.default();
119
148
  identity = index_1.JCS_SHA256;
120
- names = [];
121
149
  approvalRedactor = null;
122
150
  recordSink = null;
123
151
  maxRecords = null;
124
152
  _recordsDropped = 0;
153
+ mode;
154
+ resolver;
155
+ /** §7 emitter-wide interceptor bound; `null` disables enforcement. */
156
+ timeoutMs;
157
+ /** §9 resolver bound (`approval_resolver_ms`, §7.7.3); `null` disables. */
158
+ resolverTimeoutMs;
159
+ /** §7.7.7: a declaration-built emitter refuses reconfiguration. */
160
+ sealed = false;
161
+ /** The resolved declaration this emitter was built from, if any. */
162
+ _declaration = null;
125
163
  /**
126
164
  * `timeoutMs` bounds each interceptor `intercept()` and resolver
127
165
  * `resolve()` call (§7, RECOMMENDED default 5000 ms); breach fails
@@ -134,17 +172,151 @@ class InterceptionEmitter {
134
172
  this.mode = mode;
135
173
  this.resolver = resolver;
136
174
  this.timeoutMs = timeoutMs;
175
+ this.resolverTimeoutMs = timeoutMs;
137
176
  }
138
- /** Race `fn`'s result against the §7 timeout. */
139
- async withTimeout(fn) {
140
- if (this.timeoutMs === null)
177
+ // ---- host declaration (§7.7) ----------------------------------------------
178
+ /** Build an emitter from a validated declaration: steps 8 to 11 of
179
+ * §7.7.6 (resolve against the registry's surface and names in the
180
+ * core, then run every binding's kind resolver). The result is
181
+ * sealed (§7.7.7) and stamps `declaration` on every record (§7.7.8).
182
+ * Throws {@link DeclarationError} on refusal; no emitter exists then. */
183
+ static fromDeclaration(declaration, registry) {
184
+ const resolved = (0, declaration_1.resolveDeclaration)(declaration, registry);
185
+ return InterceptionEmitter.fromResolved(resolved, registry);
186
+ }
187
+ /** {@link HostDeclaration.fromPath} then {@link fromDeclaration}. */
188
+ static async fromDeclarationPath(path, registry) {
189
+ return InterceptionEmitter.fromDeclaration(await declaration_1.HostDeclaration.fromPath(path), registry);
190
+ }
191
+ /** {@link HostDeclaration.fromJson} then {@link fromDeclaration}. */
192
+ static fromDeclarationJson(text, registry) {
193
+ return InterceptionEmitter.fromDeclaration(declaration_1.HostDeclaration.fromJson(text), registry);
194
+ }
195
+ /** {@link HostDeclaration.fromValue} then {@link fromDeclaration}. */
196
+ static fromDeclarationValue(value, registry) {
197
+ return InterceptionEmitter.fromDeclaration(declaration_1.HostDeclaration.fromValue(value), registry);
198
+ }
199
+ /** The resolved declaration this emitter runs under, when it was
200
+ * built from one (§7.7.7). Its canonical JSON is the equivalence
201
+ * oracle for the construction paths. The value is frozen all the
202
+ * way down: writing through it has no effect on dispatch or records.
203
+ * `null` for an emitter configured in code. */
204
+ get declaration() {
205
+ return this._declaration;
206
+ }
207
+ /** Step 11 and construction. Every reference is looked up again in
208
+ * the registry's own maps, so bookkeeping drift between the names the
209
+ * core checked and what the registry holds fails closed. */
210
+ static fromResolved(resolved, registry) {
211
+ const cfg = resolved.configuration;
212
+ let resolver = null;
213
+ if (cfg.approval.resolver !== null) {
214
+ const name = cfg.approval.resolver;
215
+ const r = registry.approvalResolverRef(name);
216
+ if (r === undefined) {
217
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/approval/resolver", `approval resolver ${JSON.stringify(name)} vanished from the registry`);
218
+ }
219
+ resolver = r;
220
+ }
221
+ let identity;
222
+ if (cfg.identity_provider === null) {
223
+ identity = null;
224
+ }
225
+ else if (cfg.identity_provider === index_1.JCS_SHA256) {
226
+ identity = index_1.JCS_SHA256;
227
+ }
228
+ else {
229
+ const name = cfg.identity_provider;
230
+ const fn = registry.identityFn(name);
231
+ if (fn === undefined) {
232
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/identity_provider", `identity provider ${JSON.stringify(name)} vanished from the registry`);
233
+ }
234
+ identity = { name, fn };
235
+ }
236
+ let approvalRedactor = null;
237
+ if (cfg.approval.redactor !== null) {
238
+ const name = cfg.approval.redactor;
239
+ const fn = registry.redactorFn(name);
240
+ if (fn === undefined) {
241
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/approval/redactor", `approval redactor ${JSON.stringify(name)} vanished from the registry`);
242
+ }
243
+ approvalRedactor = fn;
244
+ }
245
+ const bound = [];
246
+ resolved.bindings.forEach((b, i) => {
247
+ const pointer = `/bindings/${i}`;
248
+ const resolverFn = registry.kindResolver(b.kind);
249
+ if (resolverFn === undefined) {
250
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.KindUnknown, `${pointer}/kind`, `kind ${JSON.stringify(b.kind)} vanished from the registry`);
251
+ }
252
+ // The dispatch set is fixed before the resolver runs and the
253
+ // context carries a frozen copy, so a resolver cannot widen its
254
+ // own binding (the Rust core hands out `&BTreeSet` for the same
255
+ // reason).
256
+ const at = new Set(b.at);
257
+ const context = {
258
+ id: b.id,
259
+ kind: b.kind,
260
+ at: Object.freeze([...b.at]),
261
+ timeoutMs: b.timeout_ms,
262
+ host: resolved.host ?? null,
263
+ declarationVersion: resolved.declaration,
264
+ };
265
+ // §7.7.5: a resolver that throws or returns a non-interceptor
266
+ // refuses the document; the message is bounded and the config is
267
+ // never echoed by the loader.
268
+ let interceptor;
269
+ try {
270
+ interceptor = resolverFn(b.config, context);
271
+ }
272
+ catch (e) {
273
+ const msg = e instanceof Error ? e.message : "resolver threw a non-Error value";
274
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.BindingRejected, pointer, `binding ${JSON.stringify(b.id)} (kind ${JSON.stringify(b.kind)}) rejected: ${msg}`);
275
+ }
276
+ if (!isInterceptor(interceptor)) {
277
+ const why = interceptor instanceof Promise
278
+ ? "resolver returned a Promise; kind resolvers are synchronous"
279
+ : "resolver did not return an interceptor";
280
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.BindingRejected, pointer, `binding ${JSON.stringify(b.id)} (kind ${JSON.stringify(b.kind)}) rejected: ${why}`);
281
+ }
282
+ bound.push({
283
+ interceptor,
284
+ name: b.id,
285
+ at,
286
+ timeoutMs: b.timeout_ms,
287
+ });
288
+ });
289
+ const em = new InterceptionEmitter(cfg.mode, resolver, cfg.timeouts.interceptor_ms);
290
+ em.resolverTimeoutMs = cfg.timeouts.approval_resolver_ms;
291
+ // The emitter owns its own copy of the composition and the
292
+ // declaration it exposes is frozen all the way down, so the seal
293
+ // cannot be bypassed by writing through `emitter.declaration`.
294
+ em.composition = { ...cfg.composition };
295
+ em.identity = identity;
296
+ em.approvalRedactor = approvalRedactor;
297
+ em.maxRecords = cfg.records.max_buffered;
298
+ em.interceptors.push(...bound);
299
+ em._declaration = deepFreeze(resolved);
300
+ em.sealed = true;
301
+ return em;
302
+ }
303
+ /** §7.7.7: a declaration-built emitter refuses reconfiguration;
304
+ * otherwise the declaration would not be what ran. */
305
+ unsealed(what) {
306
+ if (this.sealed)
307
+ throw new EmitterSealed(what);
308
+ }
309
+ /** Race `fn`'s result against a timeout (§7). `limitMs: null` runs
310
+ * unbounded. */
311
+ async withTimeout(fn, limitMs) {
312
+ if (limitMs === null)
141
313
  return fn();
142
314
  let timer;
143
315
  try {
144
316
  return await Promise.race([
145
317
  (async () => fn())(),
146
318
  new Promise((_, reject) => {
147
- timer = setTimeout(() => reject(new InterceptTimeout()), this.timeoutMs);
319
+ timer = setTimeout(() => reject(new InterceptTimeout()), limitMs);
148
320
  }),
149
321
  ]);
150
322
  }
@@ -157,10 +329,36 @@ class InterceptionEmitter {
157
329
  return this._records;
158
330
  }
159
331
  /** Register an interceptor, optionally with a host-chosen
160
- * payload-free `name` recorded on `verdicts[].name` (§10.3). */
161
- register(interceptor, name) {
162
- this.interceptors.push(interceptor);
163
- this.names.push(name ?? null);
332
+ * payload-free `name` recorded on `verdicts[].name` (§10.3) and the
333
+ * points it runs at (`at`; omitted = every point). At point P the
334
+ * interceptors that run are those bound there, in registration
335
+ * order; `interceptors_registered`, `verdicts[].index` and
336
+ * `decided_by` count and index that list (§7.7.8). Throws
337
+ * {@link EmitterSealed} on a declaration-built emitter. */
338
+ register(interceptor, name, at) {
339
+ this.unsealed("register");
340
+ let points = null;
341
+ if (at !== undefined) {
342
+ // The compile-time type does not protect plain-JS callers: a
343
+ // misspelled point must not bind nowhere without a word.
344
+ points = new Set();
345
+ const known = Object.values(index_1.InterceptionPoint);
346
+ for (const p of at) {
347
+ if (!known.includes(p)) {
348
+ throw new RangeError(`unknown interception point ${JSON.stringify(p)}: the point set is closed (§3)`);
349
+ }
350
+ points.add(p);
351
+ }
352
+ if (points.size === 0) {
353
+ throw new RangeError("at must name at least one interception point; omit it to bind every point (§7.7.5)");
354
+ }
355
+ }
356
+ this.interceptors.push({
357
+ interceptor,
358
+ name: name ?? null,
359
+ at: points,
360
+ timeoutMs: undefined,
361
+ });
164
362
  return this;
165
363
  }
166
364
  /**
@@ -178,6 +376,7 @@ class InterceptionEmitter {
178
376
  * profile. See docs/PRODUCTION.md.
179
377
  */
180
378
  setComposition(composition) {
379
+ this.unsealed("setComposition");
181
380
  const profiles = Object.values(index_1.CompositionProfile);
182
381
  if (!profiles.includes(composition.profile)) {
183
382
  throw new RangeError(`unknown composition profile ${JSON.stringify(composition.profile)}: the profile set is closed (§7.2)`);
@@ -200,6 +399,7 @@ class InterceptionEmitter {
200
399
  * `^[a-z][a-z0-9_-]*$` and must not begin with `jcs` (reserved so a
201
400
  * custom function can never claim golden-vector semantics). */
202
401
  setIdentityProvider(provider) {
402
+ this.unsealed("setIdentityProvider");
203
403
  if (provider !== null && provider !== index_1.JCS_SHA256) {
204
404
  if (!/^[a-z][a-z0-9_-]*$/.test(provider.name) || provider.name.startsWith("jcs")) {
205
405
  throw new RangeError("identity provider name must match ^[a-z][a-z0-9_-]*$ and must not begin with 'jcs' (§10.1)");
@@ -215,13 +415,15 @@ class InterceptionEmitter {
215
415
  * redactor that throws fails the consultation closed as
216
416
  * `host_error:approval_resolver_failed`. */
217
417
  setApprovalRedactor(redactor) {
418
+ this.unsealed("setApprovalRedactor");
218
419
  this.approvalRedactor = redactor;
219
420
  return this;
220
421
  }
221
422
  /** Register a per-emission record callback (§10.3), invoked
222
423
  * synchronously after every emission before buffering; a sink
223
424
  * exception is swallowed (audit delivery is the host's liveness
224
- * concern, not the control plane's). */
425
+ * concern, not the control plane's). Allowed on a sealed emitter: it
426
+ * changes where records go, not what they say (§7.7.7). */
225
427
  setRecordSink(sink) {
226
428
  this.recordSink = sink;
227
429
  return this;
@@ -230,6 +432,7 @@ class InterceptionEmitter {
230
432
  * is dropped and {@link recordsDropped} increments. Unbounded by
231
433
  * default. */
232
434
  setMaxRecords(max) {
435
+ this.unsealed("setMaxRecords");
233
436
  this.maxRecords = max;
234
437
  return this;
235
438
  }
@@ -285,8 +488,9 @@ class InterceptionEmitter {
285
488
  }
286
489
  }
287
490
  const identityFailed = outcome !== null;
491
+ const active = this.active(ctx);
288
492
  if (outcome === null)
289
- outcome = await this.dispatch(ctx);
493
+ outcome = await this.dispatch(ctx, active);
290
494
  const providerName = this.identity === null ? null : this.identity === index_1.JCS_SHA256 ? index_1.JCS_SHA256 : this.identity.name;
291
495
  const meta = {
292
496
  input_identity: inputIdentity,
@@ -300,13 +504,15 @@ class InterceptionEmitter {
300
504
  : null,
301
505
  decided_by: outcome.decidedBy,
302
506
  composition: this.composition,
507
+ declaration: this._declaration?.declaration ?? null,
508
+ // §10.3 summaries with the bound names attached positionally.
303
509
  verdicts: outcome.verdicts.map((v) => ({
304
510
  ...v,
305
- ...(this.names[v.index] != null ? { name: this.names[v.index] } : {}),
511
+ ...(active[v.index]?.name != null ? { name: active[v.index].name } : {}),
306
512
  })),
307
513
  fold_truncated: outcome.foldTruncated,
308
514
  resolved_by: outcome.resolvedBy,
309
- interceptors_registered: this.interceptors.length,
515
+ interceptors_registered: active.length,
310
516
  };
311
517
  let record;
312
518
  if (identityFailed) {
@@ -362,10 +568,11 @@ class InterceptionEmitter {
362
568
  enforced_identity: null,
363
569
  decided_by: null,
364
570
  composition: this.composition,
571
+ declaration: this._declaration?.declaration ?? null,
365
572
  verdicts: null,
366
573
  fold_truncated: null,
367
574
  resolved_by: null,
368
- interceptors_registered: this.interceptors.length,
575
+ interceptors_registered: this.interceptors.filter((b) => runsAt(b, point)).length,
369
576
  });
370
577
  return this.deliver(record);
371
578
  }
@@ -390,23 +597,34 @@ class InterceptionEmitter {
390
597
  return record;
391
598
  }
392
599
  // ---------------------------------------------------------------------------
600
+ /** The interceptors bound at the context's point, in dispatch order
601
+ * (§7.7.8). */
602
+ active(ctx) {
603
+ const point = pointOf(ctx);
604
+ return this.interceptors.filter((b) => runsAt(b, point));
605
+ }
606
+ /** The bound a registration runs under: its own, else the emitter's. */
607
+ limit(b) {
608
+ return b.timeoutMs === undefined ? this.timeoutMs : b.timeoutMs;
609
+ }
393
610
  /** Profile dispatch (§7.4–§7.5). Returns the combined verdict and
394
611
  * its record metadata. */
395
- async dispatch(ctx) {
396
- if (this.interceptors.length === 0) {
612
+ async dispatch(ctx, active) {
613
+ if (active.length === 0) {
397
614
  // §7: zero interceptors fails closed, profile-independent.
398
615
  // Register an explicit allow-all interceptor for a deliberate
399
- // passthrough.
616
+ // passthrough. With per-point bindings (§7.7.5) this is per
617
+ // point: a surface point with no binding denies.
400
618
  return synthesized(index_1.HostError.NoInterceptor);
401
619
  }
402
620
  switch (this.composition.profile) {
403
621
  case index_1.CompositionProfile.SequentialFirstDeny:
404
- return this.dispatchFirstDeny(ctx);
622
+ return this.dispatchFirstDeny(ctx, active);
405
623
  case index_1.CompositionProfile.SequentialRunAll:
406
- return this.dispatchRunAll(ctx);
624
+ return this.dispatchRunAll(ctx, active);
407
625
  case index_1.CompositionProfile.ParallelStrictest:
408
626
  case index_1.CompositionProfile.ParallelUnanimous:
409
- return this.dispatchParallel(ctx);
627
+ return this.dispatchParallel(ctx, active);
410
628
  default:
411
629
  // Unreachable through the public API: setComposition validates
412
630
  // against the closed §7.2 set. Fail closed rather than silently
@@ -417,12 +635,12 @@ class InterceptionEmitter {
417
635
  /** Invoke one interceptor on its own copy of `ctx` (§7) and pass the
418
636
  * result through the §5 gate. Never throws: every failure becomes the
419
637
  * §6.3 synthesized deny. */
420
- async invoke(interceptor, ctx) {
638
+ async invoke(bound, ctx) {
421
639
  let v;
422
640
  try {
423
641
  // §7: each interceptor gets its own copy — in-place mutation of
424
642
  // the copy cannot alter enforcement.
425
- v = await this.withTimeout(() => interceptor.intercept(structuredClone(ctx)));
643
+ v = await this.withTimeout(() => bound.interceptor.intercept(structuredClone(ctx)), this.limit(bound));
426
644
  }
427
645
  catch (e) {
428
646
  if (e instanceof InterceptTimeout) {
@@ -451,11 +669,11 @@ class InterceptionEmitter {
451
669
  * short-circuits; a liftable deny consults the seam, then `stop` or
452
670
  * `resume` per the knob.
453
671
  *
454
- * `perInterceptor` stays index-aligned with registration order (one
672
+ * `perInterceptor` stays index-aligned with dispatch order (one
455
673
  * entry per invoked interceptor, §10.3 summaries); `pool`
456
674
  * additionally holds substituted resolutions for the §7.3 unions. */
457
- async dispatchFirstDeny(ctx) {
458
- const n = this.interceptors.length;
675
+ async dispatchFirstDeny(ctx, active) {
676
+ const n = active.length;
459
677
  const onApproval = this.composition.on_approval ?? "stop";
460
678
  const perInterceptor = [];
461
679
  const pool = [];
@@ -463,7 +681,7 @@ class InterceptionEmitter {
463
681
  let resolvedBy = null;
464
682
  const truncated = (i) => i + 1 < n;
465
683
  for (let i = 0; i < n; i++) {
466
- let v = await this.invoke(this.interceptors[i], ctx);
684
+ let v = await this.invoke(active[i], ctx);
467
685
  perInterceptor.push(v);
468
686
  pool.push(v);
469
687
  if (isHostSynthesized(v)) {
@@ -560,12 +778,12 @@ class InterceptionEmitter {
560
778
  * consulted at most once, only when the winner is liftable (which
561
779
  * implies every deny in the emission is liftable — a single plain
562
780
  * deny already won the severity order). */
563
- async dispatchRunAll(ctx) {
781
+ async dispatchRunAll(ctx, active) {
564
782
  const all = [];
565
- for (const interceptor of this.interceptors) {
783
+ for (const bound of active) {
566
784
  // §6.3 per-interceptor: a malformed verdict becomes that
567
785
  // interceptor's synthesized deny; the rest still run.
568
- const v = await this.invoke(interceptor, ctx);
786
+ const v = await this.invoke(bound, ctx);
569
787
  if (v.decision === index_1.Decision.Transform) {
570
788
  const folded = this.foldTransform(ctx, v);
571
789
  if (!(0, index_1.permits)(folded.decision)) {
@@ -590,13 +808,13 @@ class InterceptionEmitter {
590
808
  }
591
809
  /** Parallel profiles (§7.5): isolated snapshots, no fold; serial
592
810
  * dispatch (isolation semantics, not scheduling). */
593
- async dispatchParallel(ctx) {
811
+ async dispatchParallel(ctx, active) {
594
812
  // Every interceptor receives its own copy of the same untransformed
595
813
  // snapshot ({@link invoke} clones per call).
596
814
  const snapshot = structuredClone(ctx);
597
815
  const all = [];
598
- for (const interceptor of this.interceptors) {
599
- all.push(await this.invoke(interceptor, snapshot));
816
+ for (const bound of active) {
817
+ all.push(await this.invoke(bound, snapshot));
600
818
  }
601
819
  return this.aggregateAndConsult(ctx, all);
602
820
  }
@@ -685,9 +903,6 @@ class InterceptionEmitter {
685
903
  }
686
904
  return v;
687
905
  }
688
- /** The declared provider's output for `ctx` (§10.1); `null` iff the
689
- * provider is `null`. Throws {@link AgentHooksCoreError} iff the
690
- * provider rejected the context (§10.2 fail-closed value domain). */
691
906
  /** Custom-provider identity, or null when the provider fails (the
692
907
  * emission has already been decided at this point). */
693
908
  tryCustomIdentity(ctx) {
@@ -698,6 +913,9 @@ class InterceptionEmitter {
698
913
  return null;
699
914
  }
700
915
  }
916
+ /** The declared provider's output for `ctx` (§10.1); `null` iff the
917
+ * provider is `null`. Throws {@link AgentHooksCoreError} iff the
918
+ * provider rejected the context (§10.2 fail-closed value domain). */
701
919
  identityOf(ctx) {
702
920
  if (this.identity === null)
703
921
  return null;
@@ -759,7 +977,7 @@ class InterceptionEmitter {
759
977
  interception_point: ctx.interception_point,
760
978
  verdict,
761
979
  context: presented,
762
- }));
980
+ }), this.resolverTimeoutMs);
763
981
  }
764
982
  catch (e) {
765
983
  if (e instanceof InterceptTimeout) {
@@ -796,6 +1014,24 @@ class InterceptionEmitter {
796
1014
  }
797
1015
  }
798
1016
  exports.InterceptionEmitter = InterceptionEmitter;
1017
+ /** The context's interception point when it names one. */
1018
+ function pointOf(ctx) {
1019
+ const p = ctx["interception_point"];
1020
+ return typeof p === "string" && Object.values(index_1.InterceptionPoint).includes(p)
1021
+ ? p
1022
+ : null;
1023
+ }
1024
+ /** Whether a registration runs at `point` (§7.7.5). A registration
1025
+ * without an `at` filter runs everywhere. An unparseable point never
1026
+ * reaches dispatch (§4 validation denies first); every binding counts
1027
+ * then so the record is conservative. */
1028
+ function runsAt(b, point) {
1029
+ if (b.at === null)
1030
+ return true;
1031
+ if (point === null)
1032
+ return true;
1033
+ return b.at.has(point);
1034
+ }
799
1035
  /** `(code, detail)` of a native failure; anything else maps to
800
1036
  * `fallback` with the stringified error as detail. */
801
1037
  function codeAndDetail(e, fallback = index_1.HostError.ContextInvalid) {
package/dist/index.d.ts CHANGED
@@ -90,6 +90,11 @@ export interface Verdict {
90
90
  evidence?: Evidence;
91
91
  result_labels?: string[];
92
92
  }
93
+ /** Optional hosted/cross-process response envelope (§5.5). */
94
+ export interface InterceptorResponse {
95
+ verdict: Verdict;
96
+ extensions?: Record<string, JsonValue>;
97
+ }
93
98
  /** Constructor sugar for the three-verdict vocabulary (§5.1). Merges
94
99
  * with the `Verdict` interface: `Verdict.warn(...)`, `Verdict.escalate(...)`. */
95
100
  export declare const Verdict: Readonly<{
@@ -137,7 +142,7 @@ export interface CompositionConfig {
137
142
  on_approval?: OnApproval;
138
143
  /** `parallel/unanimous` only. */
139
144
  on_disagreement?: SynthesisPolicy;
140
- /** Parallel profiles only. */
145
+ /** `parallel/strictest` only. */
141
146
  on_transform_conflict?: SynthesisPolicy;
142
147
  }
143
148
  /** Constructors for the closed profile set (§7.2). */
@@ -231,6 +236,11 @@ export interface InterceptionRecord {
231
236
  decided_by: number | null;
232
237
  /** The composition profile and knobs in effect (§7.1). */
233
238
  composition: CompositionConfig;
239
+ /** The host declaration contract version the emitter was built from
240
+ * (§7.7.8), e.g. `agent-hooks-declaration/1.0`. Present iff the
241
+ * emitter was constructed from a declaration document; absent for
242
+ * an emitter configured in code. */
243
+ declaration?: string;
234
244
  /** Per-interceptor summary; populated in multi-verdict profiles
235
245
  * (`sequential/run_all`, `parallel/*`). */
236
246
  verdicts?: VerdictSummary[];
@@ -242,7 +252,7 @@ export interface InterceptionRecord {
242
252
  * resolution substituted; `"rejection"` iff consulted without a
243
253
  * lift; absent iff never consulted. */
244
254
  resolved_by?: "approval" | "rejection" | null;
245
- /** Interceptors registered at emission time (§10.3). */
255
+ /** Interceptors bound at the emitted point (§10.3, §7.7.8). */
246
256
  interceptors_registered: number;
247
257
  }
248
258
  /** Whether the guarded action executes (§6, §8). */
@@ -290,6 +300,10 @@ export declare function canonicalJson(v: JsonValue): string;
290
300
  export declare function contextIdentity(ctx: AgentContext): string;
291
301
  /** §5: validate an interceptor's wire return value. Rust core. */
292
302
  export declare function validateVerdict(v: Verdict): void;
303
+ /** Parse a bare Verdict or §5.5 response envelope. */
304
+ export declare function parseInterceptorResponse(value: JsonValue | string): InterceptorResponse;
305
+ /** Emit a bare Verdict when the response carries no transport metadata. */
306
+ export declare function serializeInterceptorResponse(response: InterceptorResponse): JsonValue;
293
307
  /** §5.2: apply a `$target`-rooted transform. Returns a new object. Rust core. */
294
308
  export declare function applyTransform(target: JsonValue, path: string, value: JsonValue): JsonValue;
295
309
  /** §7.4 fold-through: apply one transform to the context's `target` (and
@@ -315,6 +329,10 @@ export interface FinalizeMeta {
315
329
  decided_by?: number | null;
316
330
  /** REQUIRED: the profile and knobs in effect (§7.1). */
317
331
  composition: CompositionConfig;
332
+ /** The declaration contract version the emitter was built from
333
+ * (§7.7.8); absent or `null` for an emitter configured in code. The
334
+ * core never defaults it. */
335
+ declaration?: string | null;
318
336
  /** Per-interceptor summaries (multi-verdict profiles, §10.3). */
319
337
  verdicts?: VerdictSummary[] | null;
320
338
  /** Sequential profiles (§7.4). */
@@ -340,8 +358,10 @@ export declare function composeAggregate(composition: CompositionConfig, verdict
340
358
  verdicts: VerdictSummary[];
341
359
  };
342
360
  export { AgentContextBuilder } from "./builder";
343
- export { InterceptionEmitter } from "./emitter";
361
+ export { InterceptionEmitter, EmitterSealed } from "./emitter";
344
362
  export type { HostFailure } from "./emitter";
363
+ export { CAPABILITIES, DECLARATION_VERSION, DeclarationBuilder, DeclarationError, DeclarationErrorClass, HostDeclaration, HostRegistry, HostRegistryError, HostSurface, MAX_BINDINGS, MAX_DEPTH, MAX_DETAIL_LEN, MAX_DOCUMENT_BYTES, MAX_ID_LEN, MAX_KIND_LEN, MAX_TIMEOUT_MS, SUPPORTED_DECLARATION_VERSIONS, canonicalDeclaration, declarationVersions, fullKnobSupport, validKind, validReference, } from "./declaration";
364
+ export type { BindingContext, Finding, HostInfo, KindResolver, KnobSupport, RegistryNames, ResolvedBinding, ResolvedConfiguration, ResolvedDeclaration, ResolvedSurface, TimeoutSupport, ToolSeamPosture, } from "./declaration";
345
365
  /** Raised by a host when a verdict blocks the guarded action (§6). */
346
366
  /** Returned by `InterceptionEmitter.emit` on a proceeding emission:
347
367
  * the record plus the **effective** (post-composition) target the