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