@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.
- package/README.md +47 -0
- package/agent-hooks.linux-x64-gnu.node +0 -0
- package/binding.d.ts +18 -0
- package/binding.js +327 -0
- package/dist/builder.d.ts +37 -0
- package/dist/builder.js +94 -0
- package/dist/ctk/index.d.ts +70 -0
- package/dist/ctk/index.js +18 -0
- package/dist/ctk/reference.d.ts +20 -0
- package/dist/ctk/reference.js +124 -0
- package/dist/ctk/runner.d.ts +26 -0
- package/dist/ctk/runner.js +144 -0
- package/dist/emitter.d.ts +111 -0
- package/dist/emitter.js +615 -0
- package/dist/index.d.ts +328 -0
- package/dist/index.js +273 -0
- package/dist/native.d.ts +28 -0
- package/dist/native.js +56 -0
- package/package.json +36 -0
package/dist/emitter.js
ADDED
|
@@ -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
|
+
}
|