@responsibleai/agent-hooks 0.1.0-alpha.5 → 0.1.0-beta.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.
- package/README.md +74 -5
- package/binding.d.ts +34 -0
- package/binding.js +164 -82
- package/dist/ctk/index.d.ts +53 -5
- package/dist/ctk/index.js +5 -1
- package/dist/ctk/reference.d.ts +15 -1
- package/dist/ctk/reference.declaration.json +33 -0
- package/dist/ctk/reference.js +84 -31
- package/dist/ctk/runner.d.ts +34 -4
- package/dist/ctk/runner.js +569 -36
- package/dist/declaration.d.ts +393 -0
- package/dist/declaration.js +752 -0
- package/dist/emitter.d.ts +111 -15
- package/dist/emitter.js +329 -41
- package/dist/index.d.ts +15 -3
- package/dist/index.js +26 -1
- package/dist/native.d.ts +8 -1
- package/dist/native.js +9 -1
- package/package.json +6 -6
package/dist/emitter.js
CHANGED
|
@@ -20,8 +20,15 @@
|
|
|
20
20
|
* serial dispatch over isolated snapshots — §7.2: parallel names
|
|
21
21
|
* isolation semantics, not scheduling.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* An emitter can also be built from a host declaration document (§7.7)
|
|
24
|
+
* through {@link InterceptionEmitter.fromDeclaration} and the path, JSON
|
|
25
|
+
* and value forms. The document is validated and resolved by the Rust
|
|
26
|
+
* core; this module runs the host's kind resolvers (step 11 of §7.7.6),
|
|
27
|
+
* seals the emitter (§7.7.7) and stamps the contract version on every
|
|
28
|
+
* record (§7.7.8).
|
|
29
|
+
*
|
|
30
|
+
* Fail-closed defaults: an `enforce`-mode emission with zero interceptors
|
|
31
|
+
* bound at the point yields `deny host_error:no_interceptor` (§7), and
|
|
25
32
|
* {@link InterceptionEmitter.emit} **throws** {@link InterceptionBlocked}
|
|
26
33
|
* on any block — the ignorable-result variant is the explicitly named
|
|
27
34
|
* {@link InterceptionEmitter.emitUnchecked}.
|
|
@@ -31,8 +38,9 @@
|
|
|
31
38
|
* a single JS thread. Sharing one emitter across workers is unsupported.
|
|
32
39
|
*/
|
|
33
40
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
-
exports.InterceptionEmitter = exports.DEFAULT_TIMEOUT_MS = void 0;
|
|
41
|
+
exports.InterceptionEmitter = exports.EmitterSealed = exports.DEFAULT_TIMEOUT_MS = void 0;
|
|
35
42
|
const index_1 = require("./index");
|
|
43
|
+
const declaration_1 = require("./declaration");
|
|
36
44
|
const native_1 = require("./native");
|
|
37
45
|
/** §7 RECOMMENDED interceptor/resolver timeout (milliseconds). */
|
|
38
46
|
exports.DEFAULT_TIMEOUT_MS = 5000;
|
|
@@ -43,6 +51,24 @@ class InterceptTimeout extends Error {
|
|
|
43
51
|
this.name = "InterceptTimeout";
|
|
44
52
|
}
|
|
45
53
|
}
|
|
54
|
+
/** Thrown when a setter or `register` is called on an emitter built
|
|
55
|
+
* from a host declaration (§7.7.7): the declaration must stay what ran. */
|
|
56
|
+
/** Recursively freeze a JSON-shaped value in place and return it. */
|
|
57
|
+
function deepFreeze(value) {
|
|
58
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
59
|
+
Object.freeze(value);
|
|
60
|
+
for (const v of Object.values(value))
|
|
61
|
+
deepFreeze(v);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
class EmitterSealed extends Error {
|
|
66
|
+
constructor(what) {
|
|
67
|
+
super(`${what}: this emitter was built from a host declaration and is sealed (see spec §7.7.7)`);
|
|
68
|
+
this.name = "EmitterSealed";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
exports.EmitterSealed = EmitterSealed;
|
|
46
72
|
/** Whether a verdict was synthesized by the host (§11) rather than
|
|
47
73
|
* returned by an interceptor or resolver. */
|
|
48
74
|
function isHostSynthesized(v) {
|
|
@@ -109,19 +135,32 @@ function synthesized(err, detail) {
|
|
|
109
135
|
};
|
|
110
136
|
}
|
|
111
137
|
const NOT_CONSULTED = { consulted: false };
|
|
138
|
+
/** Whether `v` looks like an interceptor (§7): an object with an
|
|
139
|
+
* `intercept` method. */
|
|
140
|
+
function isInterceptor(v) {
|
|
141
|
+
return (v !== null &&
|
|
142
|
+
(typeof v === "object" || typeof v === "function") &&
|
|
143
|
+
typeof v.intercept === "function");
|
|
144
|
+
}
|
|
112
145
|
class InterceptionEmitter {
|
|
113
|
-
mode;
|
|
114
|
-
resolver;
|
|
115
|
-
timeoutMs;
|
|
116
146
|
interceptors = [];
|
|
117
147
|
_records = [];
|
|
118
148
|
composition = index_1.Composition.default();
|
|
119
149
|
identity = index_1.JCS_SHA256;
|
|
120
|
-
names = [];
|
|
121
150
|
approvalRedactor = null;
|
|
122
151
|
recordSink = null;
|
|
123
152
|
maxRecords = null;
|
|
124
153
|
_recordsDropped = 0;
|
|
154
|
+
mode;
|
|
155
|
+
resolver;
|
|
156
|
+
/** §7 emitter-wide interceptor bound; `null` disables enforcement. */
|
|
157
|
+
timeoutMs;
|
|
158
|
+
/** §9 resolver bound (`approval_resolver_ms`, §7.7.3); `null` disables. */
|
|
159
|
+
resolverTimeoutMs;
|
|
160
|
+
/** §7.7.7: a declaration-built emitter refuses reconfiguration. */
|
|
161
|
+
sealed = false;
|
|
162
|
+
/** The resolved declaration this emitter was built from, if any. */
|
|
163
|
+
_declaration = null;
|
|
125
164
|
/**
|
|
126
165
|
* `timeoutMs` bounds each interceptor `intercept()` and resolver
|
|
127
166
|
* `resolve()` call (§7, RECOMMENDED default 5000 ms); breach fails
|
|
@@ -134,17 +173,151 @@ class InterceptionEmitter {
|
|
|
134
173
|
this.mode = mode;
|
|
135
174
|
this.resolver = resolver;
|
|
136
175
|
this.timeoutMs = timeoutMs;
|
|
176
|
+
this.resolverTimeoutMs = timeoutMs;
|
|
177
|
+
}
|
|
178
|
+
// ---- host declaration (§7.7) ----------------------------------------------
|
|
179
|
+
/** Build an emitter from a validated declaration: steps 8 to 11 of
|
|
180
|
+
* §7.7.6 (resolve against the registry's surface and names in the
|
|
181
|
+
* core, then run every binding's kind resolver). The result is
|
|
182
|
+
* sealed (§7.7.7) and stamps `declaration` on every record (§7.7.8).
|
|
183
|
+
* Throws {@link DeclarationError} on refusal; no emitter exists then. */
|
|
184
|
+
static fromDeclaration(declaration, registry) {
|
|
185
|
+
const resolved = (0, declaration_1.resolveDeclaration)(declaration, registry);
|
|
186
|
+
return InterceptionEmitter.fromResolved(resolved, registry);
|
|
187
|
+
}
|
|
188
|
+
/** {@link HostDeclaration.fromPath} then {@link fromDeclaration}. */
|
|
189
|
+
static async fromDeclarationPath(path, registry) {
|
|
190
|
+
return InterceptionEmitter.fromDeclaration(await declaration_1.HostDeclaration.fromPath(path), registry);
|
|
191
|
+
}
|
|
192
|
+
/** {@link HostDeclaration.fromJson} then {@link fromDeclaration}. */
|
|
193
|
+
static fromDeclarationJson(text, registry) {
|
|
194
|
+
return InterceptionEmitter.fromDeclaration(declaration_1.HostDeclaration.fromJson(text), registry);
|
|
195
|
+
}
|
|
196
|
+
/** {@link HostDeclaration.fromValue} then {@link fromDeclaration}. */
|
|
197
|
+
static fromDeclarationValue(value, registry) {
|
|
198
|
+
return InterceptionEmitter.fromDeclaration(declaration_1.HostDeclaration.fromValue(value), registry);
|
|
199
|
+
}
|
|
200
|
+
/** The resolved declaration this emitter runs under, when it was
|
|
201
|
+
* built from one (§7.7.7). Its canonical JSON is the equivalence
|
|
202
|
+
* oracle for the construction paths. The value is frozen all the
|
|
203
|
+
* way down: writing through it has no effect on dispatch or records.
|
|
204
|
+
* `null` for an emitter configured in code. */
|
|
205
|
+
get declaration() {
|
|
206
|
+
return this._declaration;
|
|
207
|
+
}
|
|
208
|
+
/** Step 11 and construction. Every reference is looked up again in
|
|
209
|
+
* the registry's own maps, so bookkeeping drift between the names the
|
|
210
|
+
* core checked and what the registry holds fails closed. */
|
|
211
|
+
static fromResolved(resolved, registry) {
|
|
212
|
+
const cfg = resolved.configuration;
|
|
213
|
+
let resolver = null;
|
|
214
|
+
if (cfg.approval.resolver !== null) {
|
|
215
|
+
const name = cfg.approval.resolver;
|
|
216
|
+
const r = registry.approvalResolverRef(name);
|
|
217
|
+
if (r === undefined) {
|
|
218
|
+
throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/approval/resolver", `approval resolver ${JSON.stringify(name)} vanished from the registry`);
|
|
219
|
+
}
|
|
220
|
+
resolver = r;
|
|
221
|
+
}
|
|
222
|
+
let identity;
|
|
223
|
+
if (cfg.identity_provider === null) {
|
|
224
|
+
identity = null;
|
|
225
|
+
}
|
|
226
|
+
else if (cfg.identity_provider === index_1.JCS_SHA256) {
|
|
227
|
+
identity = index_1.JCS_SHA256;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const name = cfg.identity_provider;
|
|
231
|
+
const fn = registry.identityFn(name);
|
|
232
|
+
if (fn === undefined) {
|
|
233
|
+
throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/identity_provider", `identity provider ${JSON.stringify(name)} vanished from the registry`);
|
|
234
|
+
}
|
|
235
|
+
identity = { name, fn };
|
|
236
|
+
}
|
|
237
|
+
let approvalRedactor = null;
|
|
238
|
+
if (cfg.approval.redactor !== null) {
|
|
239
|
+
const name = cfg.approval.redactor;
|
|
240
|
+
const fn = registry.redactorFn(name);
|
|
241
|
+
if (fn === undefined) {
|
|
242
|
+
throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/approval/redactor", `approval redactor ${JSON.stringify(name)} vanished from the registry`);
|
|
243
|
+
}
|
|
244
|
+
approvalRedactor = fn;
|
|
245
|
+
}
|
|
246
|
+
const bound = [];
|
|
247
|
+
resolved.bindings.forEach((b, i) => {
|
|
248
|
+
const pointer = `/bindings/${i}`;
|
|
249
|
+
const resolverFn = registry.kindResolver(b.kind);
|
|
250
|
+
if (resolverFn === undefined) {
|
|
251
|
+
throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.KindUnknown, `${pointer}/kind`, `kind ${JSON.stringify(b.kind)} vanished from the registry`);
|
|
252
|
+
}
|
|
253
|
+
// The dispatch set is fixed before the resolver runs and the
|
|
254
|
+
// context carries a frozen copy, so a resolver cannot widen its
|
|
255
|
+
// own binding (the Rust core hands out `&BTreeSet` for the same
|
|
256
|
+
// reason).
|
|
257
|
+
const at = new Set(b.at);
|
|
258
|
+
const context = {
|
|
259
|
+
id: b.id,
|
|
260
|
+
kind: b.kind,
|
|
261
|
+
at: Object.freeze([...b.at]),
|
|
262
|
+
timeoutMs: b.timeout_ms,
|
|
263
|
+
host: resolved.host ?? null,
|
|
264
|
+
declarationVersion: resolved.declaration,
|
|
265
|
+
};
|
|
266
|
+
// §7.7.5: a resolver that throws or returns a non-interceptor
|
|
267
|
+
// refuses the document; the message is bounded and the config is
|
|
268
|
+
// never echoed by the loader.
|
|
269
|
+
let interceptor;
|
|
270
|
+
try {
|
|
271
|
+
interceptor = resolverFn(b.config, context);
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
const msg = e instanceof Error ? e.message : "resolver threw a non-Error value";
|
|
275
|
+
throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.BindingRejected, pointer, `binding ${JSON.stringify(b.id)} (kind ${JSON.stringify(b.kind)}) rejected: ${msg}`);
|
|
276
|
+
}
|
|
277
|
+
if (!isInterceptor(interceptor)) {
|
|
278
|
+
const why = interceptor instanceof Promise
|
|
279
|
+
? "resolver returned a Promise; kind resolvers are synchronous"
|
|
280
|
+
: "resolver did not return an interceptor";
|
|
281
|
+
throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.BindingRejected, pointer, `binding ${JSON.stringify(b.id)} (kind ${JSON.stringify(b.kind)}) rejected: ${why}`);
|
|
282
|
+
}
|
|
283
|
+
bound.push({
|
|
284
|
+
interceptor,
|
|
285
|
+
name: b.id,
|
|
286
|
+
at,
|
|
287
|
+
timeoutMs: b.timeout_ms,
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
const em = new InterceptionEmitter(cfg.mode, resolver, cfg.timeouts.interceptor_ms);
|
|
291
|
+
em.resolverTimeoutMs = cfg.timeouts.approval_resolver_ms;
|
|
292
|
+
// The emitter owns its own copy of the composition and the
|
|
293
|
+
// declaration it exposes is frozen all the way down, so the seal
|
|
294
|
+
// cannot be bypassed by writing through `emitter.declaration`.
|
|
295
|
+
em.composition = { ...cfg.composition };
|
|
296
|
+
em.identity = identity;
|
|
297
|
+
em.approvalRedactor = approvalRedactor;
|
|
298
|
+
em.maxRecords = cfg.records.max_buffered;
|
|
299
|
+
em.interceptors.push(...bound);
|
|
300
|
+
em._declaration = deepFreeze(resolved);
|
|
301
|
+
em.sealed = true;
|
|
302
|
+
return em;
|
|
137
303
|
}
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
304
|
+
/** §7.7.7: a declaration-built emitter refuses reconfiguration;
|
|
305
|
+
* otherwise the declaration would not be what ran. */
|
|
306
|
+
unsealed(what) {
|
|
307
|
+
if (this.sealed)
|
|
308
|
+
throw new EmitterSealed(what);
|
|
309
|
+
}
|
|
310
|
+
/** Race `fn`'s result against a timeout (§7). `limitMs: null` runs
|
|
311
|
+
* unbounded. */
|
|
312
|
+
async withTimeout(fn, limitMs) {
|
|
313
|
+
if (limitMs === null)
|
|
141
314
|
return fn();
|
|
142
315
|
let timer;
|
|
143
316
|
try {
|
|
144
317
|
return await Promise.race([
|
|
145
318
|
(async () => fn())(),
|
|
146
319
|
new Promise((_, reject) => {
|
|
147
|
-
timer = setTimeout(() => reject(new InterceptTimeout()),
|
|
320
|
+
timer = setTimeout(() => reject(new InterceptTimeout()), limitMs);
|
|
148
321
|
}),
|
|
149
322
|
]);
|
|
150
323
|
}
|
|
@@ -157,10 +330,36 @@ class InterceptionEmitter {
|
|
|
157
330
|
return this._records;
|
|
158
331
|
}
|
|
159
332
|
/** Register an interceptor, optionally with a host-chosen
|
|
160
|
-
* payload-free `name` recorded on `verdicts[].name` (§10.3)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
333
|
+
* payload-free `name` recorded on `verdicts[].name` (§10.3) and the
|
|
334
|
+
* points it runs at (`at`; omitted = every point). At point P the
|
|
335
|
+
* interceptors that run are those bound there, in registration
|
|
336
|
+
* order; `interceptors_registered`, `verdicts[].index` and
|
|
337
|
+
* `decided_by` count and index that list (§7.7.8). Throws
|
|
338
|
+
* {@link EmitterSealed} on a declaration-built emitter. */
|
|
339
|
+
register(interceptor, name, at) {
|
|
340
|
+
this.unsealed("register");
|
|
341
|
+
let points = null;
|
|
342
|
+
if (at !== undefined) {
|
|
343
|
+
// The compile-time type does not protect plain-JS callers: a
|
|
344
|
+
// misspelled point must not bind nowhere without a word.
|
|
345
|
+
points = new Set();
|
|
346
|
+
const known = Object.values(index_1.InterceptionPoint);
|
|
347
|
+
for (const p of at) {
|
|
348
|
+
if (!known.includes(p)) {
|
|
349
|
+
throw new RangeError(`unknown interception point ${JSON.stringify(p)}: the point set is closed (§3)`);
|
|
350
|
+
}
|
|
351
|
+
points.add(p);
|
|
352
|
+
}
|
|
353
|
+
if (points.size === 0) {
|
|
354
|
+
throw new RangeError("at must name at least one interception point; omit it to bind every point (§7.7.5)");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this.interceptors.push({
|
|
358
|
+
interceptor,
|
|
359
|
+
name: name ?? null,
|
|
360
|
+
at: points,
|
|
361
|
+
timeoutMs: undefined,
|
|
362
|
+
});
|
|
164
363
|
return this;
|
|
165
364
|
}
|
|
166
365
|
/**
|
|
@@ -178,6 +377,7 @@ class InterceptionEmitter {
|
|
|
178
377
|
* profile. See docs/PRODUCTION.md.
|
|
179
378
|
*/
|
|
180
379
|
setComposition(composition) {
|
|
380
|
+
this.unsealed("setComposition");
|
|
181
381
|
const profiles = Object.values(index_1.CompositionProfile);
|
|
182
382
|
if (!profiles.includes(composition.profile)) {
|
|
183
383
|
throw new RangeError(`unknown composition profile ${JSON.stringify(composition.profile)}: the profile set is closed (§7.2)`);
|
|
@@ -200,6 +400,7 @@ class InterceptionEmitter {
|
|
|
200
400
|
* `^[a-z][a-z0-9_-]*$` and must not begin with `jcs` (reserved so a
|
|
201
401
|
* custom function can never claim golden-vector semantics). */
|
|
202
402
|
setIdentityProvider(provider) {
|
|
403
|
+
this.unsealed("setIdentityProvider");
|
|
203
404
|
if (provider !== null && provider !== index_1.JCS_SHA256) {
|
|
204
405
|
if (!/^[a-z][a-z0-9_-]*$/.test(provider.name) || provider.name.startsWith("jcs")) {
|
|
205
406
|
throw new RangeError("identity provider name must match ^[a-z][a-z0-9_-]*$ and must not begin with 'jcs' (§10.1)");
|
|
@@ -215,13 +416,15 @@ class InterceptionEmitter {
|
|
|
215
416
|
* redactor that throws fails the consultation closed as
|
|
216
417
|
* `host_error:approval_resolver_failed`. */
|
|
217
418
|
setApprovalRedactor(redactor) {
|
|
419
|
+
this.unsealed("setApprovalRedactor");
|
|
218
420
|
this.approvalRedactor = redactor;
|
|
219
421
|
return this;
|
|
220
422
|
}
|
|
221
423
|
/** Register a per-emission record callback (§10.3), invoked
|
|
222
424
|
* synchronously after every emission before buffering; a sink
|
|
223
425
|
* exception is swallowed (audit delivery is the host's liveness
|
|
224
|
-
* concern, not the control plane's).
|
|
426
|
+
* concern, not the control plane's). Allowed on a sealed emitter: it
|
|
427
|
+
* changes where records go, not what they say (§7.7.7). */
|
|
225
428
|
setRecordSink(sink) {
|
|
226
429
|
this.recordSink = sink;
|
|
227
430
|
return this;
|
|
@@ -230,6 +433,7 @@ class InterceptionEmitter {
|
|
|
230
433
|
* is dropped and {@link recordsDropped} increments. Unbounded by
|
|
231
434
|
* default. */
|
|
232
435
|
setMaxRecords(max) {
|
|
436
|
+
this.unsealed("setMaxRecords");
|
|
233
437
|
this.maxRecords = max;
|
|
234
438
|
return this;
|
|
235
439
|
}
|
|
@@ -285,8 +489,9 @@ class InterceptionEmitter {
|
|
|
285
489
|
}
|
|
286
490
|
}
|
|
287
491
|
const identityFailed = outcome !== null;
|
|
492
|
+
const active = this.active(ctx);
|
|
288
493
|
if (outcome === null)
|
|
289
|
-
outcome = await this.dispatch(ctx);
|
|
494
|
+
outcome = await this.dispatch(ctx, active);
|
|
290
495
|
const providerName = this.identity === null ? null : this.identity === index_1.JCS_SHA256 ? index_1.JCS_SHA256 : this.identity.name;
|
|
291
496
|
const meta = {
|
|
292
497
|
input_identity: inputIdentity,
|
|
@@ -300,13 +505,15 @@ class InterceptionEmitter {
|
|
|
300
505
|
: null,
|
|
301
506
|
decided_by: outcome.decidedBy,
|
|
302
507
|
composition: this.composition,
|
|
508
|
+
declaration: this._declaration?.declaration ?? null,
|
|
509
|
+
// §10.3 summaries with the bound names attached positionally.
|
|
303
510
|
verdicts: outcome.verdicts.map((v) => ({
|
|
304
511
|
...v,
|
|
305
|
-
...(
|
|
512
|
+
...(active[v.index]?.name != null ? { name: active[v.index].name } : {}),
|
|
306
513
|
})),
|
|
307
514
|
fold_truncated: outcome.foldTruncated,
|
|
308
515
|
resolved_by: outcome.resolvedBy,
|
|
309
|
-
interceptors_registered:
|
|
516
|
+
interceptors_registered: active.length,
|
|
310
517
|
};
|
|
311
518
|
let record;
|
|
312
519
|
if (identityFailed) {
|
|
@@ -320,6 +527,58 @@ class InterceptionEmitter {
|
|
|
320
527
|
else {
|
|
321
528
|
record = (0, index_1.finalize)(ctx, outcome.combined, this.mode, meta);
|
|
322
529
|
}
|
|
530
|
+
return this.deliver(record);
|
|
531
|
+
}
|
|
532
|
+
/** §10.3/§11 host projection failure: synthesize and deliver the
|
|
533
|
+
* fail-closed record for an emission whose `AgentContext` the host
|
|
534
|
+
* could not construct at all — its own projection to the wire failed
|
|
535
|
+
* before anything existed to {@link emit} (e.g. a tool-call argument
|
|
536
|
+
* getter threw during to-wire conversion at the chat seam). Without
|
|
537
|
+
* this the host can only fail the action closed *recordless*; with
|
|
538
|
+
* it the trail stays complete under host-side faults.
|
|
539
|
+
*
|
|
540
|
+
* The record is the §10.3 rejection shape: the payload-free
|
|
541
|
+
* projection of a `deny host_error:context_invalid` carrying
|
|
542
|
+
* `failure.detail` (payload-free: a type name or path, never
|
|
543
|
+
* content) as its message; `null` identities under the declared
|
|
544
|
+
* provider; `decided_by: null`; no per-interceptor summaries (no
|
|
545
|
+
* interceptor ran); envelope members from {@link HostFailure}, with
|
|
546
|
+
* the §10.3 unknown values (`""`/`-1`) where absent. It takes the
|
|
547
|
+
* next slot in the record stream (sink, then buffer) like any
|
|
548
|
+
* emission. In `enforce` mode the host MUST still fail the action
|
|
549
|
+
* closed; in `evaluate_only` the record documents the host fault
|
|
550
|
+
* without implying enforcement (§8). */
|
|
551
|
+
recordHostFailure(point, failure = {}) {
|
|
552
|
+
// Deliberately partial basis: only the envelope facts the host
|
|
553
|
+
// still knows. It never passes §4 validation (`spec` is absent),
|
|
554
|
+
// so the core's finalize always yields the §10.3 rejection shape —
|
|
555
|
+
// null identities under the declared provider — and keeps the
|
|
556
|
+
// synthesized `context_invalid` deny (with the host's detail)
|
|
557
|
+
// instead of substituting its own.
|
|
558
|
+
const basis = { interception_point: point };
|
|
559
|
+
if (failure.session_id !== undefined)
|
|
560
|
+
basis["session"] = { id: failure.session_id };
|
|
561
|
+
if (failure.sequence !== undefined)
|
|
562
|
+
basis["sequence"] = failure.sequence;
|
|
563
|
+
if (failure.timestamp !== undefined)
|
|
564
|
+
basis["timestamp"] = failure.timestamp;
|
|
565
|
+
const providerName = this.identity === null ? null : this.identity === index_1.JCS_SHA256 ? index_1.JCS_SHA256 : this.identity.name;
|
|
566
|
+
const record = (0, index_1.finalize)(basis, (0, index_1.hostErrorVerdict)(index_1.HostError.ContextInvalid, failure.detail), this.mode, {
|
|
567
|
+
input_identity: null,
|
|
568
|
+
identity_provider: providerName,
|
|
569
|
+
enforced_identity: null,
|
|
570
|
+
decided_by: null,
|
|
571
|
+
composition: this.composition,
|
|
572
|
+
declaration: this._declaration?.declaration ?? null,
|
|
573
|
+
verdicts: null,
|
|
574
|
+
fold_truncated: null,
|
|
575
|
+
resolved_by: null,
|
|
576
|
+
interceptors_registered: this.interceptors.filter((b) => runsAt(b, point)).length,
|
|
577
|
+
});
|
|
578
|
+
return this.deliver(record);
|
|
579
|
+
}
|
|
580
|
+
/** Deliver a record to the sink and the bounded buffer (§10.3). */
|
|
581
|
+
deliver(record) {
|
|
323
582
|
if (this.recordSink) {
|
|
324
583
|
// Audit delivery must not take down the control plane (§10.3).
|
|
325
584
|
try {
|
|
@@ -339,23 +598,34 @@ class InterceptionEmitter {
|
|
|
339
598
|
return record;
|
|
340
599
|
}
|
|
341
600
|
// ---------------------------------------------------------------------------
|
|
601
|
+
/** The interceptors bound at the context's point, in dispatch order
|
|
602
|
+
* (§7.7.8). */
|
|
603
|
+
active(ctx) {
|
|
604
|
+
const point = pointOf(ctx);
|
|
605
|
+
return this.interceptors.filter((b) => runsAt(b, point));
|
|
606
|
+
}
|
|
607
|
+
/** The bound a registration runs under: its own, else the emitter's. */
|
|
608
|
+
limit(b) {
|
|
609
|
+
return b.timeoutMs === undefined ? this.timeoutMs : b.timeoutMs;
|
|
610
|
+
}
|
|
342
611
|
/** Profile dispatch (§7.4–§7.5). Returns the combined verdict and
|
|
343
612
|
* its record metadata. */
|
|
344
|
-
async dispatch(ctx) {
|
|
345
|
-
if (
|
|
613
|
+
async dispatch(ctx, active) {
|
|
614
|
+
if (active.length === 0) {
|
|
346
615
|
// §7: zero interceptors fails closed, profile-independent.
|
|
347
616
|
// Register an explicit allow-all interceptor for a deliberate
|
|
348
|
-
// passthrough.
|
|
617
|
+
// passthrough. With per-point bindings (§7.7.5) this is per
|
|
618
|
+
// point: a surface point with no binding denies.
|
|
349
619
|
return synthesized(index_1.HostError.NoInterceptor);
|
|
350
620
|
}
|
|
351
621
|
switch (this.composition.profile) {
|
|
352
622
|
case index_1.CompositionProfile.SequentialFirstDeny:
|
|
353
|
-
return this.dispatchFirstDeny(ctx);
|
|
623
|
+
return this.dispatchFirstDeny(ctx, active);
|
|
354
624
|
case index_1.CompositionProfile.SequentialRunAll:
|
|
355
|
-
return this.dispatchRunAll(ctx);
|
|
625
|
+
return this.dispatchRunAll(ctx, active);
|
|
356
626
|
case index_1.CompositionProfile.ParallelStrictest:
|
|
357
627
|
case index_1.CompositionProfile.ParallelUnanimous:
|
|
358
|
-
return this.dispatchParallel(ctx);
|
|
628
|
+
return this.dispatchParallel(ctx, active);
|
|
359
629
|
default:
|
|
360
630
|
// Unreachable through the public API: setComposition validates
|
|
361
631
|
// against the closed §7.2 set. Fail closed rather than silently
|
|
@@ -366,12 +636,12 @@ class InterceptionEmitter {
|
|
|
366
636
|
/** Invoke one interceptor on its own copy of `ctx` (§7) and pass the
|
|
367
637
|
* result through the §5 gate. Never throws: every failure becomes the
|
|
368
638
|
* §6.3 synthesized deny. */
|
|
369
|
-
async invoke(
|
|
639
|
+
async invoke(bound, ctx) {
|
|
370
640
|
let v;
|
|
371
641
|
try {
|
|
372
642
|
// §7: each interceptor gets its own copy — in-place mutation of
|
|
373
643
|
// the copy cannot alter enforcement.
|
|
374
|
-
v = await this.withTimeout(() => interceptor.intercept(structuredClone(ctx)));
|
|
644
|
+
v = await this.withTimeout(() => bound.interceptor.intercept(structuredClone(ctx)), this.limit(bound));
|
|
375
645
|
}
|
|
376
646
|
catch (e) {
|
|
377
647
|
if (e instanceof InterceptTimeout) {
|
|
@@ -400,11 +670,11 @@ class InterceptionEmitter {
|
|
|
400
670
|
* short-circuits; a liftable deny consults the seam, then `stop` or
|
|
401
671
|
* `resume` per the knob.
|
|
402
672
|
*
|
|
403
|
-
* `perInterceptor` stays index-aligned with
|
|
673
|
+
* `perInterceptor` stays index-aligned with dispatch order (one
|
|
404
674
|
* entry per invoked interceptor, §10.3 summaries); `pool`
|
|
405
675
|
* additionally holds substituted resolutions for the §7.3 unions. */
|
|
406
|
-
async dispatchFirstDeny(ctx) {
|
|
407
|
-
const n =
|
|
676
|
+
async dispatchFirstDeny(ctx, active) {
|
|
677
|
+
const n = active.length;
|
|
408
678
|
const onApproval = this.composition.on_approval ?? "stop";
|
|
409
679
|
const perInterceptor = [];
|
|
410
680
|
const pool = [];
|
|
@@ -412,7 +682,7 @@ class InterceptionEmitter {
|
|
|
412
682
|
let resolvedBy = null;
|
|
413
683
|
const truncated = (i) => i + 1 < n;
|
|
414
684
|
for (let i = 0; i < n; i++) {
|
|
415
|
-
let v = await this.invoke(
|
|
685
|
+
let v = await this.invoke(active[i], ctx);
|
|
416
686
|
perInterceptor.push(v);
|
|
417
687
|
pool.push(v);
|
|
418
688
|
if (isHostSynthesized(v)) {
|
|
@@ -509,12 +779,12 @@ class InterceptionEmitter {
|
|
|
509
779
|
* consulted at most once, only when the winner is liftable (which
|
|
510
780
|
* implies every deny in the emission is liftable — a single plain
|
|
511
781
|
* deny already won the severity order). */
|
|
512
|
-
async dispatchRunAll(ctx) {
|
|
782
|
+
async dispatchRunAll(ctx, active) {
|
|
513
783
|
const all = [];
|
|
514
|
-
for (const
|
|
784
|
+
for (const bound of active) {
|
|
515
785
|
// §6.3 per-interceptor: a malformed verdict becomes that
|
|
516
786
|
// interceptor's synthesized deny; the rest still run.
|
|
517
|
-
const v = await this.invoke(
|
|
787
|
+
const v = await this.invoke(bound, ctx);
|
|
518
788
|
if (v.decision === index_1.Decision.Transform) {
|
|
519
789
|
const folded = this.foldTransform(ctx, v);
|
|
520
790
|
if (!(0, index_1.permits)(folded.decision)) {
|
|
@@ -539,13 +809,13 @@ class InterceptionEmitter {
|
|
|
539
809
|
}
|
|
540
810
|
/** Parallel profiles (§7.5): isolated snapshots, no fold; serial
|
|
541
811
|
* dispatch (isolation semantics, not scheduling). */
|
|
542
|
-
async dispatchParallel(ctx) {
|
|
812
|
+
async dispatchParallel(ctx, active) {
|
|
543
813
|
// Every interceptor receives its own copy of the same untransformed
|
|
544
814
|
// snapshot ({@link invoke} clones per call).
|
|
545
815
|
const snapshot = structuredClone(ctx);
|
|
546
816
|
const all = [];
|
|
547
|
-
for (const
|
|
548
|
-
all.push(await this.invoke(
|
|
817
|
+
for (const bound of active) {
|
|
818
|
+
all.push(await this.invoke(bound, snapshot));
|
|
549
819
|
}
|
|
550
820
|
return this.aggregateAndConsult(ctx, all);
|
|
551
821
|
}
|
|
@@ -634,9 +904,6 @@ class InterceptionEmitter {
|
|
|
634
904
|
}
|
|
635
905
|
return v;
|
|
636
906
|
}
|
|
637
|
-
/** The declared provider's output for `ctx` (§10.1); `null` iff the
|
|
638
|
-
* provider is `null`. Throws {@link AgentHooksCoreError} iff the
|
|
639
|
-
* provider rejected the context (§10.2 fail-closed value domain). */
|
|
640
907
|
/** Custom-provider identity, or null when the provider fails (the
|
|
641
908
|
* emission has already been decided at this point). */
|
|
642
909
|
tryCustomIdentity(ctx) {
|
|
@@ -647,6 +914,9 @@ class InterceptionEmitter {
|
|
|
647
914
|
return null;
|
|
648
915
|
}
|
|
649
916
|
}
|
|
917
|
+
/** The declared provider's output for `ctx` (§10.1); `null` iff the
|
|
918
|
+
* provider is `null`. Throws {@link AgentHooksCoreError} iff the
|
|
919
|
+
* provider rejected the context (§10.2 fail-closed value domain). */
|
|
650
920
|
identityOf(ctx) {
|
|
651
921
|
if (this.identity === null)
|
|
652
922
|
return null;
|
|
@@ -708,7 +978,7 @@ class InterceptionEmitter {
|
|
|
708
978
|
interception_point: ctx.interception_point,
|
|
709
979
|
verdict,
|
|
710
980
|
context: presented,
|
|
711
|
-
}));
|
|
981
|
+
}), this.resolverTimeoutMs);
|
|
712
982
|
}
|
|
713
983
|
catch (e) {
|
|
714
984
|
if (e instanceof InterceptTimeout) {
|
|
@@ -745,6 +1015,24 @@ class InterceptionEmitter {
|
|
|
745
1015
|
}
|
|
746
1016
|
}
|
|
747
1017
|
exports.InterceptionEmitter = InterceptionEmitter;
|
|
1018
|
+
/** The context's interception point when it names one. */
|
|
1019
|
+
function pointOf(ctx) {
|
|
1020
|
+
const p = ctx["interception_point"];
|
|
1021
|
+
return typeof p === "string" && Object.values(index_1.InterceptionPoint).includes(p)
|
|
1022
|
+
? p
|
|
1023
|
+
: null;
|
|
1024
|
+
}
|
|
1025
|
+
/** Whether a registration runs at `point` (§7.7.5). A registration
|
|
1026
|
+
* without an `at` filter runs everywhere. An unparseable point never
|
|
1027
|
+
* reaches dispatch (§4 validation denies first); every binding counts
|
|
1028
|
+
* then so the record is conservative. */
|
|
1029
|
+
function runsAt(b, point) {
|
|
1030
|
+
if (b.at === null)
|
|
1031
|
+
return true;
|
|
1032
|
+
if (point === null)
|
|
1033
|
+
return true;
|
|
1034
|
+
return b.at.has(point);
|
|
1035
|
+
}
|
|
748
1036
|
/** `(code, detail)` of a native failure; anything else maps to
|
|
749
1037
|
* `fallback` with the stringified error as detail. */
|
|
750
1038
|
function codeAndDetail(e, fallback = index_1.HostError.ContextInvalid) {
|
package/dist/index.d.ts
CHANGED
|
@@ -137,7 +137,7 @@ export interface CompositionConfig {
|
|
|
137
137
|
on_approval?: OnApproval;
|
|
138
138
|
/** `parallel/unanimous` only. */
|
|
139
139
|
on_disagreement?: SynthesisPolicy;
|
|
140
|
-
/**
|
|
140
|
+
/** `parallel/strictest` only. */
|
|
141
141
|
on_transform_conflict?: SynthesisPolicy;
|
|
142
142
|
}
|
|
143
143
|
/** Constructors for the closed profile set (§7.2). */
|
|
@@ -231,6 +231,11 @@ export interface InterceptionRecord {
|
|
|
231
231
|
decided_by: number | null;
|
|
232
232
|
/** The composition profile and knobs in effect (§7.1). */
|
|
233
233
|
composition: CompositionConfig;
|
|
234
|
+
/** The host declaration contract version the emitter was built from
|
|
235
|
+
* (§7.7.8), e.g. `agent-hooks-declaration/1.0`. Present iff the
|
|
236
|
+
* emitter was constructed from a declaration document; absent for
|
|
237
|
+
* an emitter configured in code. */
|
|
238
|
+
declaration?: string;
|
|
234
239
|
/** Per-interceptor summary; populated in multi-verdict profiles
|
|
235
240
|
* (`sequential/run_all`, `parallel/*`). */
|
|
236
241
|
verdicts?: VerdictSummary[];
|
|
@@ -242,7 +247,7 @@ export interface InterceptionRecord {
|
|
|
242
247
|
* resolution substituted; `"rejection"` iff consulted without a
|
|
243
248
|
* lift; absent iff never consulted. */
|
|
244
249
|
resolved_by?: "approval" | "rejection" | null;
|
|
245
|
-
/** Interceptors
|
|
250
|
+
/** Interceptors bound at the emitted point (§10.3, §7.7.8). */
|
|
246
251
|
interceptors_registered: number;
|
|
247
252
|
}
|
|
248
253
|
/** Whether the guarded action executes (§6, §8). */
|
|
@@ -315,6 +320,10 @@ export interface FinalizeMeta {
|
|
|
315
320
|
decided_by?: number | null;
|
|
316
321
|
/** REQUIRED: the profile and knobs in effect (§7.1). */
|
|
317
322
|
composition: CompositionConfig;
|
|
323
|
+
/** The declaration contract version the emitter was built from
|
|
324
|
+
* (§7.7.8); absent or `null` for an emitter configured in code. The
|
|
325
|
+
* core never defaults it. */
|
|
326
|
+
declaration?: string | null;
|
|
318
327
|
/** Per-interceptor summaries (multi-verdict profiles, §10.3). */
|
|
319
328
|
verdicts?: VerdictSummary[] | null;
|
|
320
329
|
/** Sequential profiles (§7.4). */
|
|
@@ -340,7 +349,10 @@ export declare function composeAggregate(composition: CompositionConfig, verdict
|
|
|
340
349
|
verdicts: VerdictSummary[];
|
|
341
350
|
};
|
|
342
351
|
export { AgentContextBuilder } from "./builder";
|
|
343
|
-
export { InterceptionEmitter } from "./emitter";
|
|
352
|
+
export { InterceptionEmitter, EmitterSealed } from "./emitter";
|
|
353
|
+
export type { HostFailure } from "./emitter";
|
|
354
|
+
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";
|
|
355
|
+
export type { BindingContext, Finding, HostInfo, KindResolver, KnobSupport, RegistryNames, ResolvedBinding, ResolvedConfiguration, ResolvedDeclaration, ResolvedSurface, TimeoutSupport, ToolSeamPosture, } from "./declaration";
|
|
344
356
|
/** Raised by a host when a verdict blocks the guarded action (§6). */
|
|
345
357
|
/** Returned by `InterceptionEmitter.emit` on a proceeding emission:
|
|
346
358
|
* the record plus the **effective** (post-composition) target the
|