@responsibleai/agent-hooks 0.1.0-alpha.2

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.
@@ -0,0 +1,615 @@
1
+ "use strict";
2
+ // Copyright (c) Microsoft Corporation.
3
+ // Licensed under the MIT License.
4
+ /**
5
+ * Host-side emitter: dispatch context → interceptors → composition →
6
+ * combined verdict → record (§6–§10).
7
+ *
8
+ * Per-language orchestrator over the Rust core:
9
+ *
10
+ * - Interceptor dispatch (§7) and approval-seam resolution (§9) stay here
11
+ * because they call back into user JS code.
12
+ * - Verdict validation (§5), transform application (§5.2, §7.4),
13
+ * severity-max aggregation (§7.3, via `composeAggregate`), identity
14
+ * computation (§10), and record building (§10.3) delegate to the Rust
15
+ * core so behaviour is byte-identical across SDKs.
16
+ *
17
+ * Composition is host configuration (§7.1): the profile is set on the
18
+ * emitter (default `sequential/first_deny, on_approval: stop`) and
19
+ * recorded on every emission. "Parallel" profiles are implemented with
20
+ * serial dispatch over isolated snapshots — §7.2: parallel names
21
+ * isolation semantics, not scheduling.
22
+ *
23
+ * Fail-closed defaults: an `enforce`-mode emission with zero registered
24
+ * interceptors yields `deny host_error:no_interceptor` (§7), and
25
+ * {@link InterceptionEmitter.emit} **throws** {@link InterceptionBlocked}
26
+ * on any block — the ignorable-result variant is the explicitly named
27
+ * {@link InterceptionEmitter.emitUnchecked}.
28
+ *
29
+ * Concurrency (§12.2): emissions for different tool calls may interleave
30
+ * on the event loop; sequence assignment and record append are atomic on
31
+ * a single JS thread. Sharing one emitter across workers is unsupported.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.InterceptionEmitter = exports.DEFAULT_TIMEOUT_MS = void 0;
35
+ const index_1 = require("./index");
36
+ const native_1 = require("./native");
37
+ /** §7 RECOMMENDED interceptor/resolver timeout (milliseconds). */
38
+ exports.DEFAULT_TIMEOUT_MS = 5000;
39
+ /** Internal sentinel for a §7 timeout breach. */
40
+ class InterceptTimeout extends Error {
41
+ constructor() {
42
+ super("interceptor/resolver timeout");
43
+ this.name = "InterceptTimeout";
44
+ }
45
+ }
46
+ /** Whether a verdict was synthesized by the host (§11) rather than
47
+ * returned by an interceptor or resolver. */
48
+ function isHostSynthesized(v) {
49
+ return v.reason?.startsWith("host_error:") ?? false;
50
+ }
51
+ // ---- §7.3 metadata unions (mirrors sdk/rust/core/src/composition.rs) --------
52
+ /** First-seen-ordered union of `warnings` from every verdict (§7.3). */
53
+ function unionWarnings(pool) {
54
+ const out = [];
55
+ const seen = new Set();
56
+ for (const v of pool) {
57
+ for (const w of v.warnings ?? []) {
58
+ const key = JSON.stringify([w.reason ?? null, w.message ?? null]);
59
+ if (!seen.has(key)) {
60
+ seen.add(key);
61
+ out.push(w);
62
+ }
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ /** First-seen-ordered union of `result_labels` from every **permit**
68
+ * verdict (§7.3; §5.4 drops labels when the emission does not proceed). */
69
+ function unionLabels(pool) {
70
+ const out = [];
71
+ for (const v of pool) {
72
+ if (!(0, index_1.permits)(v.decision))
73
+ continue;
74
+ for (const l of v.result_labels ?? []) {
75
+ if (!out.includes(l))
76
+ out.push(l);
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** Apply the §7.3 metadata unions to a combined verdict. */
82
+ function withUnions(combined, pool) {
83
+ const out = { ...combined };
84
+ const warnings = unionWarnings(pool);
85
+ if (warnings.length > 0)
86
+ out.warnings = warnings;
87
+ if ((0, index_1.permits)(out.decision)) {
88
+ const labels = unionLabels(pool);
89
+ if (labels.length > 0)
90
+ out.result_labels = labels;
91
+ }
92
+ return out;
93
+ }
94
+ /** Payload-free per-interceptor summaries for the record (§10.3). */
95
+ function summaries(verdicts) {
96
+ return verdicts.map((v, index) => ({
97
+ index,
98
+ decision: v.decision,
99
+ ...(v.reason != null ? { reason: v.reason } : {}),
100
+ }));
101
+ }
102
+ function synthesized(err, detail) {
103
+ return {
104
+ combined: (0, index_1.hostErrorVerdict)(err, detail),
105
+ decidedBy: null,
106
+ verdicts: [],
107
+ foldTruncated: null,
108
+ resolvedBy: null,
109
+ };
110
+ }
111
+ const NOT_CONSULTED = { consulted: false };
112
+ class InterceptionEmitter {
113
+ mode;
114
+ resolver;
115
+ timeoutMs;
116
+ interceptors = [];
117
+ _records = [];
118
+ composition = index_1.Composition.default();
119
+ identity = index_1.JCS_SHA256;
120
+ /**
121
+ * `timeoutMs` bounds each interceptor `intercept()` and resolver
122
+ * `resolve()` call (§7, RECOMMENDED default 5000 ms); breach fails
123
+ * closed with `host_error:interceptor_timeout` (interceptor) or
124
+ * `host_error:approval_resolver_failed` (resolver). Only async work
125
+ * can be preempted — a synchronous interceptor that blocks the event
126
+ * loop cannot be interrupted. `timeoutMs: null` disables enforcement.
127
+ */
128
+ constructor(mode = index_1.EnforcementMode.Enforce, resolver = null, timeoutMs = exports.DEFAULT_TIMEOUT_MS) {
129
+ this.mode = mode;
130
+ this.resolver = resolver;
131
+ this.timeoutMs = timeoutMs;
132
+ }
133
+ /** Race `fn`'s result against the §7 timeout. */
134
+ async withTimeout(fn) {
135
+ if (this.timeoutMs === null)
136
+ return fn();
137
+ let timer;
138
+ try {
139
+ return await Promise.race([
140
+ (async () => fn())(),
141
+ new Promise((_, reject) => {
142
+ timer = setTimeout(() => reject(new InterceptTimeout()), this.timeoutMs);
143
+ }),
144
+ ]);
145
+ }
146
+ finally {
147
+ if (timer !== undefined)
148
+ clearTimeout(timer);
149
+ }
150
+ }
151
+ get records() {
152
+ return this._records;
153
+ }
154
+ register(interceptor) {
155
+ this.interceptors.push(interceptor);
156
+ return this;
157
+ }
158
+ /** Declare the composition profile for subsequent emissions (§7.1). */
159
+ setComposition(composition) {
160
+ this.composition = composition;
161
+ return this;
162
+ }
163
+ /** Declare the identity provider (§10.1). Default `"jcs-sha256"`. */
164
+ setIdentityProvider(provider) {
165
+ this.identity = provider;
166
+ return this;
167
+ }
168
+ /** Run the emission and **throw** {@link InterceptionBlocked} if the
169
+ * guarded action must not proceed (§6). Primary entry point. */
170
+ async emit(ctx) {
171
+ const record = await this.emitUnchecked(ctx);
172
+ if (!(0, index_1.proceeds)(record))
173
+ throw new index_1.InterceptionBlocked(record);
174
+ return record;
175
+ }
176
+ /** Run the emission and return the record without throwing. The
177
+ * caller MUST inspect `proceeds` and halt the guarded action itself;
178
+ * prefer {@link emit}. */
179
+ async emitUnchecked(ctx) {
180
+ // §4.4/P-004 marshalling guard: JSON.stringify silently corrupts
181
+ // NaN/±Infinity to null, so scan BEFORE any serialization and fail
182
+ // closed. No interceptor runs on a context the guard rejected.
183
+ let inputIdentity = null;
184
+ let outcome = null;
185
+ const hit = (0, index_1.findNonFinite)(ctx);
186
+ if (hit !== null) {
187
+ outcome = synthesized(index_1.HostError.ContextInvalid, (0, index_1.nonFiniteDetail)(hit));
188
+ }
189
+ 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.
193
+ try {
194
+ inputIdentity = this.identityOf(ctx);
195
+ }
196
+ catch (e) {
197
+ // §10.2: the default provider rejected the value domain. Fail
198
+ // closed before any interceptor runs.
199
+ outcome = synthesized(...codeAndDetail(e));
200
+ }
201
+ }
202
+ const identityFailed = outcome !== null;
203
+ if (outcome === null)
204
+ outcome = await this.dispatch(ctx);
205
+ const providerName = this.identity === null ? null : this.identity === index_1.JCS_SHA256 ? index_1.JCS_SHA256 : this.identity.name;
206
+ const meta = {
207
+ input_identity: inputIdentity,
208
+ identity_provider: providerName,
209
+ enforced_identity:
210
+ // Custom providers only; the core computes jcs-sha256 itself.
211
+ !identityFailed && this.identity !== null && this.identity !== index_1.JCS_SHA256
212
+ ? this.identity.fn(ctx)
213
+ : null,
214
+ decided_by: outcome.decidedBy,
215
+ composition: this.composition,
216
+ verdicts: outcome.verdicts,
217
+ fold_truncated: outcome.foldTruncated,
218
+ resolved_by: outcome.resolvedBy,
219
+ };
220
+ let record;
221
+ if (identityFailed) {
222
+ // The provider rejected the context (or the marshalling guard
223
+ // did), so the core must not recompute an enforced identity from
224
+ // a serialization the guard would have refused: pass a null
225
+ // provider and restore the declared name on the record.
226
+ record = JSON.parse(native_1.native.finalize(JSON.stringify(ctx), JSON.stringify(outcome.combined), this.mode, JSON.stringify({ ...meta, identity_provider: null })));
227
+ record.identity_provider = providerName;
228
+ }
229
+ else {
230
+ record = (0, index_1.finalize)(ctx, outcome.combined, this.mode, meta);
231
+ }
232
+ this._records.push(record);
233
+ return record;
234
+ }
235
+ // ---------------------------------------------------------------------------
236
+ /** Profile dispatch (§7.4–§7.5). Returns the combined verdict and
237
+ * its record metadata. */
238
+ async dispatch(ctx) {
239
+ if (this.interceptors.length === 0) {
240
+ // §7: zero interceptors fails closed, profile-independent.
241
+ // Register an explicit allow-all interceptor for a deliberate
242
+ // passthrough.
243
+ return synthesized(index_1.HostError.NoInterceptor);
244
+ }
245
+ switch (this.composition.profile) {
246
+ case index_1.CompositionProfile.SequentialFirstDeny:
247
+ return this.dispatchFirstDeny(ctx);
248
+ case index_1.CompositionProfile.SequentialRunAll:
249
+ return this.dispatchRunAll(ctx);
250
+ default:
251
+ return this.dispatchParallel(ctx);
252
+ }
253
+ }
254
+ /** Invoke one interceptor on its own copy of `ctx` (§7) and pass the
255
+ * result through the §5 gate. Never throws: every failure becomes the
256
+ * §6.3 synthesized deny. */
257
+ async invoke(interceptor, ctx) {
258
+ let v;
259
+ try {
260
+ // §7: each interceptor gets its own copy — in-place mutation of
261
+ // the copy cannot alter enforcement.
262
+ v = await this.withTimeout(() => interceptor.intercept(structuredClone(ctx)));
263
+ }
264
+ catch (e) {
265
+ if (e instanceof InterceptTimeout) {
266
+ return (0, index_1.hostErrorVerdict)(index_1.HostError.InterceptorTimeout);
267
+ }
268
+ return (0, index_1.hostErrorVerdict)(index_1.HostError.InterceptorFailed, e?.constructor?.name ?? "Error");
269
+ }
270
+ return this.gate(v);
271
+ }
272
+ /** §5 gate: normalize a wire verdict or synthesize the §6.3 deny. The
273
+ * non-finite pre-scan runs first — JSON.stringify would silently
274
+ * corrupt NaN/±Infinity to null before the core could reject them. */
275
+ gate(v) {
276
+ const hit = (0, index_1.findNonFinite)(v);
277
+ if (hit !== null)
278
+ return (0, index_1.hostErrorVerdict)(index_1.HostError.VerdictInvalid, (0, index_1.nonFiniteDetail)(hit));
279
+ try {
280
+ return JSON.parse(native_1.native.validateVerdict(JSON.stringify(v)));
281
+ }
282
+ catch (e) {
283
+ const [code, detail] = codeAndDetail(e, index_1.HostError.VerdictInvalid);
284
+ return (0, index_1.hostErrorVerdict)(code, detail);
285
+ }
286
+ }
287
+ /** `sequential/first_deny` (§7.4): fold-through, first deny
288
+ * short-circuits; a liftable deny consults the seam, then `stop` or
289
+ * `resume` per the knob.
290
+ *
291
+ * `perInterceptor` stays index-aligned with registration order (one
292
+ * entry per invoked interceptor, §10.3 summaries); `pool`
293
+ * additionally holds substituted resolutions for the §7.3 unions. */
294
+ async dispatchFirstDeny(ctx) {
295
+ const n = this.interceptors.length;
296
+ const onApproval = this.composition.on_approval ?? "stop";
297
+ const perInterceptor = [];
298
+ const pool = [];
299
+ let lastTransform = null;
300
+ let resolvedBy = null;
301
+ const truncated = (i) => i + 1 < n;
302
+ for (let i = 0; i < n; i++) {
303
+ let v = await this.invoke(this.interceptors[i], ctx);
304
+ perInterceptor.push(v);
305
+ pool.push(v);
306
+ if (isHostSynthesized(v)) {
307
+ // §6.3: malformed verdict fails closed and — in this profile —
308
+ // short-circuits like any deny. The failure deny is attributed
309
+ // to the failing interceptor (§10.3 decided_by), matching the
310
+ // aggregation profiles.
311
+ return {
312
+ combined: withUnions(v, pool),
313
+ decidedBy: i,
314
+ verdicts: summaries(perInterceptor),
315
+ foldTruncated: truncated(i),
316
+ resolvedBy,
317
+ };
318
+ }
319
+ if (v.decision === index_1.Decision.Deny) {
320
+ const c = await this.consult(ctx, v);
321
+ if (!c.consulted) {
322
+ return {
323
+ combined: withUnions(v, pool),
324
+ decidedBy: i,
325
+ verdicts: summaries(perInterceptor),
326
+ foldTruncated: truncated(i),
327
+ resolvedBy,
328
+ };
329
+ }
330
+ if (!c.permitted) {
331
+ // Reject / unresolved / echo violation: a deny stands (§9).
332
+ return {
333
+ combined: withUnions(c.verdict, pool),
334
+ decidedBy: isHostSynthesized(c.verdict) ? null : i,
335
+ verdicts: summaries(perInterceptor),
336
+ foldTruncated: truncated(i),
337
+ resolvedBy,
338
+ };
339
+ }
340
+ resolvedBy = "approval";
341
+ // §7.6: the permit resolution substitutes at this position; its
342
+ // transform folds like an interceptor's (§7.4).
343
+ const sub = c.verdict.decision === index_1.Decision.Transform ? this.foldTransform(ctx, c.verdict) : c.verdict;
344
+ if (!(0, index_1.permits)(sub.decision)) {
345
+ return {
346
+ combined: sub,
347
+ decidedBy: null,
348
+ verdicts: summaries(perInterceptor),
349
+ foldTruncated: truncated(i),
350
+ resolvedBy,
351
+ };
352
+ }
353
+ pool.push(sub);
354
+ if (onApproval === "stop") {
355
+ // §7.4 stop: the resolution is the combined verdict; the
356
+ // emission ends. fold_truncated makes the skip legible.
357
+ return {
358
+ combined: withUnions(sub, pool),
359
+ decidedBy: i,
360
+ verdicts: summaries(perInterceptor),
361
+ foldTruncated: truncated(i),
362
+ resolvedBy,
363
+ };
364
+ }
365
+ // resume: the fold continues at i+1.
366
+ if (sub.decision === index_1.Decision.Transform)
367
+ lastTransform = [i, sub];
368
+ }
369
+ else if (v.decision === index_1.Decision.Transform) {
370
+ v = this.foldTransform(ctx, v);
371
+ if (!(0, index_1.permits)(v.decision)) {
372
+ // Transform failed closed (host-synthesized §5.2).
373
+ return {
374
+ combined: v,
375
+ decidedBy: null,
376
+ verdicts: summaries(perInterceptor),
377
+ foldTruncated: truncated(i),
378
+ resolvedBy,
379
+ };
380
+ }
381
+ lastTransform = [i, v];
382
+ }
383
+ }
384
+ // No standing deny: combined is the last transform, else allow.
385
+ const [combined, decidedBy] = lastTransform !== null ? [lastTransform[1], lastTransform[0]] : [index_1.Verdict.allow(), null];
386
+ return {
387
+ combined: withUnions(combined, pool),
388
+ decidedBy,
389
+ verdicts: summaries(perInterceptor),
390
+ foldTruncated: false,
391
+ resolvedBy,
392
+ };
393
+ }
394
+ /** `sequential/run_all` (§7.4): everything runs, transforms fold
395
+ * through for visibility, severity-max aggregate; the seam is
396
+ * consulted at most once, only when the winner is liftable (which
397
+ * implies every deny in the emission is liftable — a single plain
398
+ * deny already won the severity order). */
399
+ async dispatchRunAll(ctx) {
400
+ const all = [];
401
+ for (const interceptor of this.interceptors) {
402
+ // §6.3 per-interceptor: a malformed verdict becomes that
403
+ // interceptor's synthesized deny; the rest still run.
404
+ const v = await this.invoke(interceptor, ctx);
405
+ if (v.decision === index_1.Decision.Transform) {
406
+ const folded = this.foldTransform(ctx, v);
407
+ if (!(0, index_1.permits)(folded.decision)) {
408
+ // §7.4: a transform that fails to apply short-circuits in
409
+ // both sequential profiles.
410
+ all.push(folded);
411
+ return {
412
+ combined: folded,
413
+ decidedBy: null,
414
+ verdicts: summaries(all),
415
+ foldTruncated: null,
416
+ resolvedBy: null,
417
+ };
418
+ }
419
+ all.push(folded);
420
+ }
421
+ else {
422
+ all.push(v);
423
+ }
424
+ }
425
+ return this.aggregateAndConsult(ctx, all);
426
+ }
427
+ /** Parallel profiles (§7.5): isolated snapshots, no fold; serial
428
+ * dispatch (isolation semantics, not scheduling). */
429
+ async dispatchParallel(ctx) {
430
+ // Every interceptor receives its own copy of the same untransformed
431
+ // snapshot ({@link invoke} clones per call).
432
+ const snapshot = structuredClone(ctx);
433
+ const all = [];
434
+ for (const interceptor of this.interceptors) {
435
+ all.push(await this.invoke(interceptor, snapshot));
436
+ }
437
+ return this.aggregateAndConsult(ctx, all);
438
+ }
439
+ /** Severity-max aggregation + winner handling, shared by `run_all`
440
+ * and both parallel profiles. Aggregation — including the §7.5
441
+ * transform-conflict and `parallel/unanimous` disagreement synthesis
442
+ * per the declared knobs — delegates to the core's
443
+ * `composeAggregate` so all SDKs agree; the seam consultation and
444
+ * transform application stay here (they call back into user JS). */
445
+ async aggregateAndConsult(ctx, all) {
446
+ let agg;
447
+ try {
448
+ agg = JSON.parse(native_1.native.composeAggregate(JSON.stringify(this.composition), JSON.stringify(all)));
449
+ }
450
+ catch (e) {
451
+ return synthesized(...codeAndDetail(e));
452
+ }
453
+ let combined = agg.combined;
454
+ let decidedBy = agg.decided_by;
455
+ let resolvedBy = null;
456
+ // Parallel winner: apply the single winning transform now
457
+ // (sequential transforms already folded during dispatch).
458
+ if (agg.apply_transform) {
459
+ combined = this.foldTransform(ctx, combined);
460
+ if (!(0, index_1.permits)(combined.decision)) {
461
+ return {
462
+ combined,
463
+ decidedBy: null,
464
+ verdicts: agg.verdicts,
465
+ foldTruncated: null,
466
+ resolvedBy,
467
+ };
468
+ }
469
+ }
470
+ // The combined verdict is a liftable deny the profile says to
471
+ // consult (§7.4–§7.5); environment checks (resolver present, mode,
472
+ // shutdown) live in {@link consult}.
473
+ if (agg.consult) {
474
+ const c = await this.consult(ctx, combined);
475
+ if (c.consulted) {
476
+ if (c.permitted) {
477
+ resolvedBy = "approval";
478
+ // §7.6: the resolution substitutes; a transform applies on
479
+ // top of the context as composed so far.
480
+ const sub = c.verdict.decision === index_1.Decision.Transform
481
+ ? this.foldTransform(ctx, c.verdict)
482
+ : c.verdict;
483
+ combined = (0, index_1.permits)(sub.decision) ? withUnions(sub, [...all, sub]) : sub;
484
+ }
485
+ else {
486
+ combined = withUnions(c.verdict, all);
487
+ if (isHostSynthesized(c.verdict))
488
+ decidedBy = null;
489
+ }
490
+ }
491
+ }
492
+ return {
493
+ combined,
494
+ decidedBy,
495
+ verdicts: agg.verdicts,
496
+ foldTruncated: null,
497
+ resolvedBy,
498
+ };
499
+ }
500
+ /** Apply (enforce) or validate (evaluate_only) one transform (§7.4, §8). */
501
+ foldTransform(ctx, v) {
502
+ const t = v.transform;
503
+ if (!t)
504
+ return (0, index_1.hostErrorVerdict)(index_1.HostError.TransformInvalid);
505
+ try {
506
+ if (this.mode === index_1.EnforcementMode.Enforce) {
507
+ const newCtx = JSON.parse(native_1.native.applyTransformCtx(JSON.stringify(ctx), t.path, JSON.stringify(t.value)));
508
+ for (const k of Object.keys(ctx))
509
+ delete ctx[k];
510
+ Object.assign(ctx, newCtx);
511
+ }
512
+ else {
513
+ native_1.native.validateTransformCtx(JSON.stringify(ctx), t.path, JSON.stringify(t.value));
514
+ }
515
+ }
516
+ catch (e) {
517
+ const [code, detail] = codeAndDetail(e, index_1.HostError.TransformInvalid);
518
+ return (0, index_1.hostErrorVerdict)(code, detail);
519
+ }
520
+ return v;
521
+ }
522
+ /** The declared provider's output for `ctx` (§10.1); `null` iff the
523
+ * provider is `null`. Throws {@link AgentHooksCoreError} iff the
524
+ * provider rejected the context (§10.2 fail-closed value domain). */
525
+ identityOf(ctx) {
526
+ if (this.identity === null)
527
+ return null;
528
+ if (this.identity === index_1.JCS_SHA256)
529
+ return native_1.native.contextIdentity(JSON.stringify(ctx));
530
+ try {
531
+ return this.identity.fn(ctx);
532
+ }
533
+ catch (e) {
534
+ throw new native_1.AgentHooksCoreError(index_1.HostError.ContextInvalid, `identity provider ${this.identity.name} raised: ${e?.message ?? e}`);
535
+ }
536
+ }
537
+ /** Consult the approval seam for a liftable deny (§9), when the
538
+ * profile conditions allow it: `enforce` mode, not `agent_shutdown`,
539
+ * a resolver registered, and the verdict actually liftable. Enforces
540
+ * the echo rule and the §9 outcome/verdict consistency requirements. */
541
+ async consult(ctx, verdict) {
542
+ if (!(0, index_1.isLiftable)(verdict) || this.mode !== index_1.EnforcementMode.Enforce)
543
+ return NOT_CONSULTED;
544
+ // §6.1a: nothing to approve at agent_shutdown.
545
+ if (ctx.interception_point === index_1.InterceptionPoint.AgentShutdown)
546
+ return NOT_CONSULTED;
547
+ // §9: no resolver → the deny stands. Conformant, not an error.
548
+ if (!this.resolver)
549
+ return NOT_CONSULTED;
550
+ const fail = (err, detail) => ({
551
+ consulted: true,
552
+ verdict: (0, index_1.hostErrorVerdict)(err, detail),
553
+ permitted: false,
554
+ });
555
+ // §9: identity of the context as presented to the resolver —
556
+ // consultation time, after any transforms that folded earlier.
557
+ let identity;
558
+ try {
559
+ identity = this.identityOf(ctx);
560
+ }
561
+ catch (e) {
562
+ const [code, detail] = codeAndDetail(e);
563
+ return fail(code, detail);
564
+ }
565
+ let res;
566
+ try {
567
+ res = await this.withTimeout(() => this.resolver.resolve({
568
+ context_identity: identity,
569
+ interception_point: ctx.interception_point,
570
+ verdict,
571
+ context: ctx,
572
+ }));
573
+ }
574
+ catch (e) {
575
+ if (e instanceof InterceptTimeout) {
576
+ return fail(index_1.HostError.ApprovalResolverFailed, "timeout");
577
+ }
578
+ return fail(index_1.HostError.ApprovalResolverFailed, e?.constructor?.name ?? "Error");
579
+ }
580
+ // §9 echo rule (byte-for-byte; null echoes as null).
581
+ if ((res.context_identity ?? null) !== identity) {
582
+ return fail(index_1.HostError.ApprovalIdentityMismatch);
583
+ }
584
+ if (!res.verdict || res.outcome === index_1.ApprovalOutcome.Unresolved) {
585
+ return fail(index_1.HostError.ApprovalUnresolved);
586
+ }
587
+ // §9: the resolver's verdict crosses the same §5 gate as an
588
+ // interceptor's, and outcome/decision must agree (approve MUST
589
+ // carry a permit, reject MUST carry a deny).
590
+ const rv = this.gate(res.verdict);
591
+ if (isHostSynthesized(rv)) {
592
+ return fail(index_1.HostError.VerdictInvalid, rv.message ?? undefined);
593
+ }
594
+ let permitted;
595
+ if (res.outcome === index_1.ApprovalOutcome.Approve) {
596
+ if (!(0, index_1.permits)(rv.decision))
597
+ return fail(index_1.HostError.VerdictInvalid, "approve with a block verdict");
598
+ permitted = true;
599
+ }
600
+ else {
601
+ if (rv.decision !== index_1.Decision.Deny)
602
+ return fail(index_1.HostError.VerdictInvalid, "reject with a permit verdict");
603
+ permitted = false;
604
+ }
605
+ return { consulted: true, verdict: rv, permitted };
606
+ }
607
+ }
608
+ exports.InterceptionEmitter = InterceptionEmitter;
609
+ /** `(code, detail)` of a native failure; anything else maps to
610
+ * `fallback` with the stringified error as detail. */
611
+ function codeAndDetail(e, fallback = index_1.HostError.ContextInvalid) {
612
+ if (e instanceof native_1.AgentHooksCoreError)
613
+ return [e.code, e.message];
614
+ return [fallback, String(e)];
615
+ }