agent-sanitizer 2.9.0 → 2.10.0
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 +35 -5
- package/claude-hooks/lib/hook-io.mjs +41 -1
- package/claude-hooks/lib/trace.mjs +46 -0
- package/claude-hooks/plugin-hooks.mjs +1 -0
- package/claude-hooks/pretooluse-sanitize.mjs +34 -11
- package/claude-hooks/sanitize-output.mjs +32 -5
- package/claude-hooks/sanitize-user-prompt.mjs +62 -31
- package/claude-hooks/scan-invisible-chars.mjs +12 -5
- package/package.json +1 -1
- package/types/claude-hooks/lib/hook-io.d.mts +20 -1
- package/types/claude-hooks/lib/trace.d.mts +39 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +13 -4
- package/types/claude-hooks/sanitize-output.d.mts +36 -4
- package/types/claude-hooks/sanitize-user-prompt.d.mts +20 -5
- package/types/claude-hooks/scan-invisible-chars.d.mts +6 -1
package/README.md
CHANGED
|
@@ -145,11 +145,12 @@ surface is the `--hook=` CLI, so these move between minor versions.
|
|
|
145
145
|
`cliMain`, so a composer that wraps `cliMain` gets the hook's exact fail-closed
|
|
146
146
|
CLI wiring plus its own policy:
|
|
147
147
|
|
|
148
|
-
| Field | Runs | Does
|
|
149
|
-
| ------------ | -------------------------------------------------------- |
|
|
150
|
-
| `postText` | once per string **value** leaf, after Layers 1–4 | returns `{cleaned?, warning?}`; `cleaned` replaces the model-facing text
|
|
151
|
-
| `redactNote` | on the pre-redaction text of a leaf that tripped Layer 4 | returns a note appended to that leaf's redaction warning
|
|
152
|
-
| `audit` | once per judged event carrying a tool response | is handed the output the model will actually see
|
|
148
|
+
| Field | Runs | Does |
|
|
149
|
+
| ------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
150
|
+
| `postText` | once per string **value** leaf, after Layers 1–4 | returns `{cleaned?, warning?}`; `cleaned` replaces the model-facing text |
|
|
151
|
+
| `redactNote` | on the pre-redaction text of a leaf that tripped Layer 4 | returns a note appended to that leaf's redaction warning |
|
|
152
|
+
| `audit` | once per judged event carrying a tool response | is handed the output the model will actually see, and the `session_id` it belongs to |
|
|
153
|
+
| `trace` | on every exit, in place of the package's trace channel | receives the engagement announcement (see the trace sink below) |
|
|
153
154
|
|
|
154
155
|
Omit the bag and every seam is inert — the verdicts are byte-identical to this
|
|
155
156
|
module alone. A callback that throws is **not** caught: it lands in the CLI's
|
|
@@ -172,6 +173,35 @@ secret-shaped output is suppressed, not shown unvetted. Layers 1–3 still run.
|
|
|
172
173
|
never supply the `/output` seam's `filterInjection` callback, so nothing here
|
|
173
174
|
calls a model or leaves the machine.
|
|
174
175
|
|
|
176
|
+
**Every hook's trace sink is injectable.** Each one announces that it engaged —
|
|
177
|
+
that is what makes a layer that stopped running loud rather than silent — and by
|
|
178
|
+
default that announcement goes to `_AGENT_SANITIZER_TRACE` /
|
|
179
|
+
`_AGENT_SANITIZER_TRACE_FILE`. A host that already runs a trace channel under
|
|
180
|
+
its own variables passes its own sink instead, so the announcement lands where
|
|
181
|
+
its detector actually reads:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
import { cliMain } from "agent-sanitizer/claude-hooks/scan-invisible-chars";
|
|
185
|
+
await cliMain({ trace: (event, fields) => myChannel.emit(event, fields) });
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The sink rides each hook's options bag — `cliMain({trace})` on
|
|
189
|
+
`scan-invisible-chars` and `pretooluse-sanitize`, the extension bag's `trace` on
|
|
190
|
+
`sanitize-output`, `main(read, write, {trace})` on `sanitize-user-prompt`. It
|
|
191
|
+
receives the same `TraceEvent` names the default emits, and it **replaces** the
|
|
192
|
+
default rather than running alongside it — the package channel goes silent, so
|
|
193
|
+
there is one announcement to detect, not two. It may throw freely: each hook
|
|
194
|
+
binds the sink it is given through `bestEffortTrace`, so an announcement can
|
|
195
|
+
never be the thing that breaks a hook.
|
|
196
|
+
|
|
197
|
+
**A host's own cold-start marker can replace the derived one.** The hooks wait
|
|
198
|
+
out an in-flight dependency install by polling a marker file whose path they
|
|
199
|
+
derive from `CLAUDE_PROJECT_DIR`; a host whose setup script already writes one
|
|
200
|
+
calls `configureHookgateMarker(path)` (from `lib/hook-io`) before importing any
|
|
201
|
+
hook module, and every consumer waits on that path instead. `lib/control-plane`
|
|
202
|
+
resolves the marker at module scope, so a call that lands after that import
|
|
203
|
+
warns on stderr — it cannot steer the wait that already started.
|
|
204
|
+
|
|
175
205
|
Hook internals are tuned by `_AGENT_SANITIZER_*` variables (redactor daemon
|
|
176
206
|
path/socket/timeouts, sanitize budget, trace channel, Layer-2 reveal dir). The
|
|
177
207
|
leading underscore marks them unstable — the supported surface is the `--hook=`
|
|
@@ -394,6 +394,43 @@ export function emitHookResponse(hookEventName, fields) {
|
|
|
394
394
|
/** The marker filename stem; the project directory is appended to it. */
|
|
395
395
|
const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
|
|
396
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Host-supplied marker path, replacing the derived one. Null (the default) keeps
|
|
399
|
+
* the derivation below.
|
|
400
|
+
* @type {string | null}
|
|
401
|
+
*/
|
|
402
|
+
let hookgateMarkerOverride = null;
|
|
403
|
+
|
|
404
|
+
/** Whether {@link hookgateMarkerPath} has already handed a path to a caller. */
|
|
405
|
+
let hookgateMarkerResolved = false;
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Adopt a host's own cold-start marker path in place of the derived one, so a
|
|
409
|
+
* host whose setup script already writes a marker under its own convention can
|
|
410
|
+
* use these hooks without running a second, disagreeing wait loop against a path
|
|
411
|
+
* nothing writes.
|
|
412
|
+
*
|
|
413
|
+
* ORDERING, same rule as {@link registerLazyModules}: call this before importing
|
|
414
|
+
* any hook module. `lib/control-plane.mjs` resolves the marker at MODULE scope,
|
|
415
|
+
* so a call that lands after that import cannot reach the wait it was meant to
|
|
416
|
+
* steer. A late call is reported on stderr rather than thrown: a throw at a
|
|
417
|
+
* bundle entry's top level kills the hook process before it writes a response,
|
|
418
|
+
* and a hook that emits nothing is read as non-blocking — the fail-OPEN this
|
|
419
|
+
* whole file is built to avoid. The late call still takes effect for every
|
|
420
|
+
* later resolution.
|
|
421
|
+
* @param {string | null} path absolute marker path, or null to restore the derivation
|
|
422
|
+
* @returns {void}
|
|
423
|
+
*/
|
|
424
|
+
export function configureHookgateMarker(path) {
|
|
425
|
+
if (hookgateMarkerResolved)
|
|
426
|
+
process.stderr.write(
|
|
427
|
+
"agent-sanitizer: configureHookgateMarker called after a marker path was " +
|
|
428
|
+
"already resolved; whatever resolved it is using the previous path and " +
|
|
429
|
+
"cannot be re-steered. Call it before importing any hook module.\n",
|
|
430
|
+
);
|
|
431
|
+
hookgateMarkerOverride = path;
|
|
432
|
+
}
|
|
433
|
+
|
|
397
434
|
/**
|
|
398
435
|
* Path of the cold-start in-flight marker a host's setup script writes
|
|
399
436
|
* SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
|
|
@@ -405,7 +442,8 @@ const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
|
|
|
405
442
|
* the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
|
|
406
443
|
* canonicalization — the two must produce byte-identical paths), so no env has
|
|
407
444
|
* to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
|
|
408
|
-
* setup ran → nothing to wait on)
|
|
445
|
+
* setup ran → nothing to wait on), or whatever a host set via
|
|
446
|
+
* {@link configureHookgateMarker}.
|
|
409
447
|
* @param {string | undefined} [projectDir]
|
|
410
448
|
* @param {string | undefined} [runtimeDir]
|
|
411
449
|
* @returns {string | null}
|
|
@@ -414,6 +452,8 @@ export function hookgateMarkerPath(
|
|
|
414
452
|
projectDir = process.env.CLAUDE_PROJECT_DIR,
|
|
415
453
|
runtimeDir = process.env.XDG_RUNTIME_DIR,
|
|
416
454
|
) {
|
|
455
|
+
hookgateMarkerResolved = true;
|
|
456
|
+
if (hookgateMarkerOverride !== null) return hookgateMarkerOverride;
|
|
417
457
|
if (!projectDir) return null;
|
|
418
458
|
// Prefer the per-user, mode-0700 runtime dir when the harness gives an
|
|
419
459
|
// absolute one; else the world-writable /tmp, where markerIsTrusted() — not
|
|
@@ -9,10 +9,28 @@
|
|
|
9
9
|
*
|
|
10
10
|
* METADATA ONLY — never pass a tool_input body or secret material as a field; the
|
|
11
11
|
* channel is not redaction-aware.
|
|
12
|
+
*
|
|
13
|
+
* The sink is INJECTABLE. A host that already runs a trace channel under its own
|
|
14
|
+
* environment variables — and a detector that reds when a defense layer stops
|
|
15
|
+
* announcing itself — passes its own {@link TraceFn} to each hook's entry point
|
|
16
|
+
* (`cliMain`, or `main` for the prompt gate) instead of forking this module. Left
|
|
17
|
+
* unsupplied, every hook uses {@link trace} below, so the shipped behavior is
|
|
18
|
+
* unchanged.
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
21
|
import { appendFileSync } from "node:fs";
|
|
15
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The sink shape a hook emits through: the event name, its metadata fields, and
|
|
25
|
+
* the level. A host implementation receives the same {@link TraceEvent} names the
|
|
26
|
+
* default emits, so it can remap them onto its own channel's vocabulary.
|
|
27
|
+
*
|
|
28
|
+
* A sink is NOT required to be total — throw freely. Every hook binds the one it
|
|
29
|
+
* was given through {@link bestEffortTrace}, which is what upholds the channel's
|
|
30
|
+
* never-breaks-a-hook posture on host code that cannot promise it.
|
|
31
|
+
* @typedef {(event: string, fields?: Record<string, unknown>, level?: "info"|"debug") => void} TraceFn
|
|
32
|
+
*/
|
|
33
|
+
|
|
16
34
|
/** Trace-channel event names. */
|
|
17
35
|
export const TraceEvent = Object.freeze({
|
|
18
36
|
HOOK_RAN: "hook_ran",
|
|
@@ -58,3 +76,31 @@ export function trace(event, fields = {}, level = "info") {
|
|
|
58
76
|
// best-effort: a trace we can't write must never break a hook.
|
|
59
77
|
}
|
|
60
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `sink` with {@link trace}'s best-effort posture forced onto it: a throw is
|
|
82
|
+
* swallowed, so an announcement can never break the hook making it.
|
|
83
|
+
*
|
|
84
|
+
* This is what makes the sink safely injectable. The announcement call sites were
|
|
85
|
+
* placed under the guarantee that emitting cannot fail, and one of them relies on
|
|
86
|
+
* it outright: scan-invisible-chars announces BEFORE it auto-cleans the
|
|
87
|
+
* contaminated instruction files and arms the PreToolUse gate, with no catch
|
|
88
|
+
* above it, so a throwing sink there would abort the scan — leaving the payload on
|
|
89
|
+
* disk, the gate un-armed, and NO announcement on any channel. The loss the
|
|
90
|
+
* announcement exists to make loud would itself be silent.
|
|
91
|
+
*
|
|
92
|
+
* Swallowing is right here and is not licence to swallow elsewhere in this tree:
|
|
93
|
+
* a dropped announcement is already loud in the host's own detector — that is what
|
|
94
|
+
* a trace channel is — whereas a killed hook is loud nowhere.
|
|
95
|
+
* @param {TraceFn} sink
|
|
96
|
+
* @returns {TraceFn}
|
|
97
|
+
*/
|
|
98
|
+
export function bestEffortTrace(sink) {
|
|
99
|
+
return (event, fields, level) => {
|
|
100
|
+
try {
|
|
101
|
+
sink(event, fields, level);
|
|
102
|
+
} catch {
|
|
103
|
+
// See above: an announcement must never be the thing that breaks a hook.
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -34,6 +34,7 @@ const LAZY_LOADERS = {
|
|
|
34
34
|
"agent-sanitizer/confusables": () => import("agent-sanitizer/confusables"),
|
|
35
35
|
"agent-sanitizer/invisible": () => import("agent-sanitizer/invisible"),
|
|
36
36
|
"agent-sanitizer/output": () => import("agent-sanitizer/output"),
|
|
37
|
+
"agent-sanitizer/prompt": () => import("agent-sanitizer/prompt"),
|
|
37
38
|
"agent-sanitizer/rehydrate": () => import("agent-sanitizer/rehydrate"),
|
|
38
39
|
"namespace-guard": () => import("namespace-guard"),
|
|
39
40
|
};
|
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
authoredContext,
|
|
51
51
|
} from "./lib/authored-content.mjs";
|
|
52
52
|
import { redactViaDaemon } from "./lib/redactor-client.mjs";
|
|
53
|
-
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
53
|
+
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
54
54
|
|
|
55
55
|
const HOOK_NAME = "pretooluse-sanitize";
|
|
56
56
|
|
|
@@ -72,6 +72,7 @@ const HOOK_NAME = "pretooluse-sanitize";
|
|
|
72
72
|
* unknownEvent: string,
|
|
73
73
|
* failed: (cause: string) => string,
|
|
74
74
|
* unparsable: (cause: string) => string,
|
|
75
|
+
* remedy: string,
|
|
75
76
|
* }>}
|
|
76
77
|
*/
|
|
77
78
|
export const PRE_TOOL_USE_MESSAGES = Object.freeze({
|
|
@@ -79,6 +80,11 @@ export const PRE_TOOL_USE_MESSAGES = Object.freeze({
|
|
|
79
80
|
"PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
|
|
80
81
|
failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
|
|
81
82
|
unparsable: (cause) => `PreToolUse input unparsable (fail-closed): ${cause}`,
|
|
83
|
+
// What a reader should run when a dependency is what is missing. It rides in
|
|
84
|
+
// this table rather than a separate argument because it is host text exactly
|
|
85
|
+
// like the reasons above, and one channel means a host cannot supply its
|
|
86
|
+
// wording in one place and forget it in the other.
|
|
87
|
+
remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
82
88
|
});
|
|
83
89
|
|
|
84
90
|
// Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
|
|
@@ -144,18 +150,19 @@ const defaultRehydrate = (tool, toolInput) =>
|
|
|
144
150
|
* it unchanged. The trace lives on this in-process, mutation-tested path, not in
|
|
145
151
|
* the CLI block, so engagement is announced (hook_ran — metadata only: hook
|
|
146
152
|
* name, tool, outcome) for every exit.
|
|
153
|
+
* @param {import("./lib/trace.mjs").TraceFn} emitTrace
|
|
147
154
|
* @param {string} toolName
|
|
148
155
|
* @param {Record<string, unknown> | null} fields
|
|
149
156
|
* @returns {Record<string, unknown> | null}
|
|
150
157
|
*/
|
|
151
|
-
function emitTraced(toolName, fields) {
|
|
158
|
+
function emitTraced(emitTrace, toolName, fields) {
|
|
152
159
|
let outcome = "modified";
|
|
153
160
|
if (fields === null) outcome = "noop";
|
|
154
161
|
else if (fields.permissionDecision === PermissionDecision.DENY)
|
|
155
162
|
outcome = "deny";
|
|
156
163
|
else if (fields.permissionDecision === PermissionDecision.ASK)
|
|
157
164
|
outcome = "ask";
|
|
158
|
-
|
|
165
|
+
emitTrace(TraceEvent.HOOK_RAN, { hook: HOOK_NAME, tool: toolName, outcome });
|
|
159
166
|
return fields;
|
|
160
167
|
}
|
|
161
168
|
|
|
@@ -167,12 +174,18 @@ function emitTraced(toolName, fields) {
|
|
|
167
174
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
168
175
|
* injectable for tests; the default binds the real redactor-daemon io (the
|
|
169
176
|
* layer reads the target file and maps secrets through the daemon)
|
|
177
|
+
* @param {import("./lib/trace.mjs").TraceFn} [sink] where engagement is
|
|
178
|
+
* announced; a host with its own trace channel passes its sink (see lib/trace.mjs)
|
|
170
179
|
* @returns {Promise<Record<string, unknown> | null>}
|
|
171
180
|
*/
|
|
172
181
|
export async function buildPreToolUseResponse(
|
|
173
182
|
input,
|
|
174
183
|
rehydrate = defaultRehydrate,
|
|
184
|
+
sink = trace,
|
|
175
185
|
) {
|
|
186
|
+
// Every path into the announcement runs through here, so this is the one place
|
|
187
|
+
// a host sink has to be made best-effort (see bestEffortTrace).
|
|
188
|
+
const emitTrace = bestEffortTrace(sink);
|
|
176
189
|
const asks = [];
|
|
177
190
|
const contexts = [];
|
|
178
191
|
|
|
@@ -221,7 +234,7 @@ export async function buildPreToolUseResponse(
|
|
|
221
234
|
// above, so it returns immediately.
|
|
222
235
|
const rehydrated = await rehydrate(tool, current);
|
|
223
236
|
if (rehydrated && "deny" in rehydrated)
|
|
224
|
-
return emitTraced(input.tool_name, {
|
|
237
|
+
return emitTraced(emitTrace, input.tool_name, {
|
|
225
238
|
permissionDecision: PermissionDecision.DENY,
|
|
226
239
|
permissionDecisionReason: rehydrated.deny,
|
|
227
240
|
});
|
|
@@ -232,6 +245,7 @@ export async function buildPreToolUseResponse(
|
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
return emitTraced(
|
|
248
|
+
emitTrace,
|
|
235
249
|
input.tool_name,
|
|
236
250
|
assembleResponse({ changed, current, asks, contexts, pendingGateAck }),
|
|
237
251
|
);
|
|
@@ -284,12 +298,16 @@ function assembleResponse({
|
|
|
284
298
|
* fail-closed posture holds even when the adapter never loaded.
|
|
285
299
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
286
300
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
287
|
-
* @param {{
|
|
301
|
+
* @param {{
|
|
302
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
303
|
+
* gates?: HostGate[],
|
|
304
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
305
|
+
* }} [opts]
|
|
288
306
|
* messages are merged over the defaults, so a partial table is supported
|
|
289
307
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
290
308
|
*/
|
|
291
309
|
export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
292
|
-
const { gates = [] } = opts;
|
|
310
|
+
const { gates = [], trace: emitTrace = trace } = opts;
|
|
293
311
|
// MERGED over the defaults, never substituted for them. A host that overrides
|
|
294
312
|
// one field would otherwise leave the rest undefined, and the miss lands in
|
|
295
313
|
// the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
|
|
@@ -321,7 +339,7 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
|
321
339
|
const denyReason = gate(input);
|
|
322
340
|
if (denyReason) return { decision: Decision.DENY, reason: denyReason };
|
|
323
341
|
}
|
|
324
|
-
const fields = await buildPreToolUseResponse(input, rehydrate);
|
|
342
|
+
const fields = await buildPreToolUseResponse(input, rehydrate, emitTrace);
|
|
325
343
|
if (fields === null) return { decision: Decision.ALLOW };
|
|
326
344
|
/** @type {Record<string, unknown>} */
|
|
327
345
|
const verdict = {
|
|
@@ -413,16 +431,21 @@ export function failClosedFields(parsedOk, err, opts = {}) {
|
|
|
413
431
|
* @param {{
|
|
414
432
|
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
415
433
|
* gates?: HostGate[],
|
|
416
|
-
*
|
|
434
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
417
435
|
* }} [opts]
|
|
418
436
|
* @returns {Promise<void>}
|
|
419
437
|
*/
|
|
420
438
|
export async function cliMain(opts = {}) {
|
|
421
|
-
const { gates = [],
|
|
439
|
+
const { gates = [], trace: emitTrace = trace } = opts;
|
|
422
440
|
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
423
441
|
await runJudgeCli(
|
|
424
442
|
HOOK_NAME,
|
|
425
|
-
(event) =>
|
|
443
|
+
(event) =>
|
|
444
|
+
judgePreToolUseSanitize(event, undefined, {
|
|
445
|
+
messages,
|
|
446
|
+
gates,
|
|
447
|
+
trace: emitTrace,
|
|
448
|
+
}),
|
|
426
449
|
{
|
|
427
450
|
// Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
|
|
428
451
|
// hard-denies (adversary-inducible, no benefit to failing); any throw
|
|
@@ -434,7 +457,7 @@ export async function cliMain(opts = {}) {
|
|
|
434
457
|
HookEvent.PRE_TOOL_USE,
|
|
435
458
|
failClosedFields(input !== undefined, err, {
|
|
436
459
|
messages,
|
|
437
|
-
hint: depLoadHint(err, remedy),
|
|
460
|
+
hint: depLoadHint(err, messages.remedy),
|
|
438
461
|
}),
|
|
439
462
|
),
|
|
440
463
|
},
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
HookEvent,
|
|
35
35
|
} from "./lib/hook-io.mjs";
|
|
36
36
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
37
|
-
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
37
|
+
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
38
38
|
import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
|
|
39
39
|
import {
|
|
40
40
|
persistReveal,
|
|
@@ -192,9 +192,22 @@ async function redactSecrets(text, webIngress = false, deadline) {
|
|
|
192
192
|
* @property {(raw: string) => string | undefined} [redactNote]
|
|
193
193
|
* Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
|
|
194
194
|
* appended to that leaf's "API keys/secrets redacted: …" warning.
|
|
195
|
-
* @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
195
|
+
* @property {(record: { tool: string | null, session_id?: string, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
196
196
|
* Awaited once per judged event that carried a tool response, with the output
|
|
197
|
-
* the model will actually see.
|
|
197
|
+
* the model will actually see. `session_id` is the harness's session identity,
|
|
198
|
+
* lifted from the event's `meta` — an audit trail that cannot say WHICH session
|
|
199
|
+
* produced a record cannot be read back per-session, and the tool fields alone
|
|
200
|
+
* do not carry it. Absent when the payload omitted it.
|
|
201
|
+
* @property {import("./lib/trace.mjs").TraceFn} [trace]
|
|
202
|
+
* Where this hook announces engagement. A host that already runs a trace
|
|
203
|
+
* channel under its own environment variables passes its sink here, so the
|
|
204
|
+
* announcement lands where its detector reads instead of on this package's
|
|
205
|
+
* channel. Defaults to lib/trace.mjs's `trace`.
|
|
206
|
+
* @property {string} [remedy]
|
|
207
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
208
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
209
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
210
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
198
211
|
*/
|
|
199
212
|
|
|
200
213
|
/**
|
|
@@ -528,14 +541,19 @@ export function failClosedContext(
|
|
|
528
541
|
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
529
542
|
* @param {string} message
|
|
530
543
|
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
544
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
531
545
|
* @returns {void}
|
|
532
546
|
*/
|
|
533
547
|
export function emitFailClosed(
|
|
534
548
|
input,
|
|
535
549
|
message,
|
|
536
550
|
emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
|
|
551
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
537
552
|
) {
|
|
538
|
-
|
|
553
|
+
// Threaded rather than defaulted here: this is the ONLY production caller of
|
|
554
|
+
// failClosedContext, so a remedy it does not pass is a remedy no host can ever
|
|
555
|
+
// reach — the parameter would be live only from tests.
|
|
556
|
+
const additionalContext = failClosedContext(sanitizerDepsLoaded, remedy);
|
|
539
557
|
try {
|
|
540
558
|
emit({
|
|
541
559
|
updatedToolOutput: failClosedReplacement(input, message),
|
|
@@ -562,13 +580,16 @@ export function emitFailClosed(
|
|
|
562
580
|
* @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
|
|
563
581
|
*/
|
|
564
582
|
export async function evaluateToolOutput(input, ext = {}) {
|
|
583
|
+
// Best-effort, like the default sink: a host callback that throws must not be
|
|
584
|
+
// the thing that suppresses a tool output (see bestEffortTrace).
|
|
585
|
+
const emitTrace = bestEffortTrace(ext.trace ?? trace);
|
|
565
586
|
/**
|
|
566
587
|
* @param {string} outcome noop | clean | flagged | modified
|
|
567
588
|
* @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
|
|
568
589
|
* @returns {{ mutated_output?: unknown, additional_context?: string } | null}
|
|
569
590
|
*/
|
|
570
591
|
const emit = (outcome, fields) => {
|
|
571
|
-
|
|
592
|
+
emitTrace(TraceEvent.HOOK_RAN, {
|
|
572
593
|
hook: HOOK_NAME,
|
|
573
594
|
tool: input.tool_name,
|
|
574
595
|
outcome,
|
|
@@ -706,6 +727,10 @@ export async function judgeSanitizeOutput(event, ext = {}) {
|
|
|
706
727
|
const modified = fields !== null && Object.hasOwn(fields, "mutated_output");
|
|
707
728
|
await ext.audit({
|
|
708
729
|
tool: event.tool,
|
|
730
|
+
// The session identity travels in `meta`, not alongside the tool fields, so
|
|
731
|
+
// a recorder filing one trail per session cannot reach it unless it is
|
|
732
|
+
// lifted here.
|
|
733
|
+
session_id: event.meta?.session_id,
|
|
709
734
|
modified,
|
|
710
735
|
output: modified ? fields?.mutated_output : event.response,
|
|
711
736
|
context: fields?.additional_context,
|
|
@@ -774,6 +799,8 @@ export async function cliMain(ext = {}) {
|
|
|
774
799
|
"[SANITIZATION FAILED — original output suppressed for safety. Hook error: " +
|
|
775
800
|
safeErrMessage(err) +
|
|
776
801
|
"]",
|
|
802
|
+
undefined,
|
|
803
|
+
ext.remedy,
|
|
777
804
|
),
|
|
778
805
|
},
|
|
779
806
|
);
|
|
@@ -22,17 +22,20 @@ import {
|
|
|
22
22
|
readStdinJson,
|
|
23
23
|
safeErrMessage,
|
|
24
24
|
isMain,
|
|
25
|
+
lazyImport,
|
|
25
26
|
missingPackageError,
|
|
27
|
+
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
26
28
|
} from "./lib/hook-io.mjs";
|
|
27
29
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
28
|
-
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
30
|
+
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
29
31
|
// classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
|
|
30
32
|
// come from the agent-sanitizer package. They are bound by a *caught* dynamic
|
|
31
33
|
// import, never a bare top-level `import … from "…"`: a static npm import
|
|
32
34
|
// resolves before any try/catch, so a missing node_modules would crash this hook
|
|
33
35
|
// at load and let the prompt through UNSANITIZED (fail-open). A failed load
|
|
34
|
-
// leaves
|
|
35
|
-
// fail-closed block
|
|
36
|
+
// leaves that binding undefined, which the judge's typeof guards turn into a
|
|
37
|
+
// fail-closed block — one guard each, since the two loads succeed or fail
|
|
38
|
+
// independently.
|
|
36
39
|
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
37
40
|
export let classifyPrompt;
|
|
38
41
|
/** @type {typeof import("agent-sanitizer").stripAnsiFully} */
|
|
@@ -49,6 +52,7 @@ let stripAnsiFully;
|
|
|
49
52
|
* blockContext: string,
|
|
50
53
|
* sgrNote: string,
|
|
51
54
|
* hookFailed: (cause: string) => string,
|
|
55
|
+
* remedy: string,
|
|
52
56
|
* }>}
|
|
53
57
|
*/
|
|
54
58
|
export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
@@ -59,24 +63,31 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
|
59
63
|
"The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.",
|
|
60
64
|
hookFailed: (cause) =>
|
|
61
65
|
`sanitize-user-prompt hook failed (fail-closed): ${cause}`,
|
|
66
|
+
// What a reader should run when the package itself is what is missing. It
|
|
67
|
+
// rides in this table rather than a separate argument because it is host text
|
|
68
|
+
// exactly like the reasons above, and one channel means a host cannot supply
|
|
69
|
+
// its wording in one place and forget it in the other.
|
|
70
|
+
remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
62
71
|
});
|
|
63
72
|
|
|
64
73
|
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
65
74
|
* run, and their failure (the package absent) can't be simulated in-process, so
|
|
66
|
-
* neither arm is observable to the in-process tests.
|
|
75
|
+
* neither arm is observable to the in-process tests. The judge's typeof guard
|
|
67
76
|
* converts an undefined stripper into a fail-closed block — that guard IS tested. */
|
|
68
77
|
// Stryker disable all
|
|
69
|
-
try
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
78
|
+
// lazyImport rather than a bare `await import` in a try/catch: it RECORDS the
|
|
79
|
+
// loader error, which is the only thing that can tell a missing install apart
|
|
80
|
+
// from a present package missing an export. Discarding it leaves
|
|
81
|
+
// missingPackageError with nothing to report but a guess.
|
|
82
|
+
// The cast asserts the loaded SHAPE, not that it loaded: lazyImport yields {} on
|
|
83
|
+
// failure, so either binding can still be undefined here — which is exactly what
|
|
84
|
+
// the judge's typeof guard turns into a fail-closed block.
|
|
85
|
+
({ classifyPrompt } = /** @type {typeof import("agent-sanitizer/prompt")} */ (
|
|
86
|
+
await lazyImport("agent-sanitizer/prompt")
|
|
87
|
+
));
|
|
88
|
+
({ stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
|
|
89
|
+
await lazyImport("agent-sanitizer")
|
|
90
|
+
));
|
|
80
91
|
// Stryker restore all
|
|
81
92
|
/* c8 ignore stop */
|
|
82
93
|
|
|
@@ -114,11 +125,21 @@ export function judgeSanitizeUserPrompt(
|
|
|
114
125
|
return { decision: Decision.DENY, reason: messages.unknownEvent };
|
|
115
126
|
if (event.event !== EventKind.PROMPT_SUBMIT)
|
|
116
127
|
return { decision: Decision.ALLOW };
|
|
117
|
-
// The module-load guard
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
128
|
+
// The module-load guard, one arm per binding. The two lazyImports are
|
|
129
|
+
// INDEPENDENT — each yields {} on its own failure — so a present stripper does
|
|
130
|
+
// not prove the classifier loaded, and guarding on it alone would let a
|
|
131
|
+
// classifier-only failure reach `classifyPrompt(...)` as a bare TypeError
|
|
132
|
+
// naming no package, no cause and no remedy: the exact diagnostic this hook
|
|
133
|
+
// now exists to produce. lazyImportErrorFor matches subpaths, so naming
|
|
134
|
+
// `agent-sanitizer/prompt` still recovers the recorded cause.
|
|
135
|
+
if (typeof strip !== "function")
|
|
136
|
+
throw missingPackageError("agent-sanitizer", undefined, messages.remedy);
|
|
137
|
+
if (typeof classifyPrompt !== "function")
|
|
138
|
+
throw missingPackageError(
|
|
139
|
+
"agent-sanitizer/prompt",
|
|
140
|
+
undefined,
|
|
141
|
+
messages.remedy,
|
|
142
|
+
);
|
|
122
143
|
// The contract guarantees a string here: every adapter normalizes the
|
|
123
144
|
// prompt-submit input (Claude's parse coerces a missing/non-string prompt to
|
|
124
145
|
// "" via asString), so a defensive typeof re-check is a dead branch.
|
|
@@ -138,20 +159,30 @@ export function judgeSanitizeUserPrompt(
|
|
|
138
159
|
}
|
|
139
160
|
|
|
140
161
|
/**
|
|
162
|
+
* `read` and `write` stay positional — every caller supplies both — while the
|
|
163
|
+
* injectable seams ride in one bag, so a host supplying only the last of them does
|
|
164
|
+
* not have to pass `undefined` for the others.
|
|
141
165
|
* @param {() => Promise<any> | any} read
|
|
142
166
|
* @param {(chunk: string) => void} write
|
|
143
|
-
* @param {
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
167
|
+
* @param {{
|
|
168
|
+
* strip?: ((s: string) => string) | null,
|
|
169
|
+
* overrides?: Partial<typeof USER_PROMPT_MESSAGES>,
|
|
170
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
171
|
+
* }} [opts]
|
|
172
|
+
* `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
|
|
173
|
+
* injectable so the fail-closed path is testable); `overrides` are reason
|
|
174
|
+
* overrides, merged over the defaults so a partial table can never leave a field
|
|
175
|
+
* unset; `trace` is where engagement is announced, for a host with its own trace
|
|
176
|
+
* channel (see lib/trace.mjs).
|
|
147
177
|
* @returns {Promise<void>}
|
|
148
178
|
*/
|
|
149
|
-
export async function main(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
179
|
+
export async function main(read, write, opts = {}) {
|
|
180
|
+
const {
|
|
181
|
+
strip = stripAnsiFully,
|
|
182
|
+
overrides = USER_PROMPT_MESSAGES,
|
|
183
|
+
trace: sink = trace,
|
|
184
|
+
} = opts;
|
|
185
|
+
const emitTrace = bestEffortTrace(sink);
|
|
155
186
|
// Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
|
|
156
187
|
// call site where a missing field would throw out of the catch and fail OPEN.
|
|
157
188
|
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
@@ -168,7 +199,7 @@ export async function main(
|
|
|
168
199
|
const verdict = judgeSanitizeUserPrompt(event, strip, messages);
|
|
169
200
|
// Announce engagement on the trace channel like the other stdin hooks —
|
|
170
201
|
// a prompt gate that silently stopped running is otherwise invisible.
|
|
171
|
-
|
|
202
|
+
emitTrace(TraceEvent.HOOK_RAN, {
|
|
172
203
|
hook: "sanitize-user-prompt",
|
|
173
204
|
outcome:
|
|
174
205
|
verdict.decision === controlPlane().Decision.DENY
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
ALERT_ACK_FILE,
|
|
22
22
|
PROJECT_DIR,
|
|
23
23
|
} from "./lib/invisible-alert.mjs";
|
|
24
|
-
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
24
|
+
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
25
25
|
|
|
26
26
|
// Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
|
|
27
27
|
// hazard of a bare static npm import — here the instruction files would load
|
|
@@ -271,9 +271,16 @@ function scanProject() {
|
|
|
271
271
|
* the alert for the PreToolUse gate otherwise. Exported so a bundle entry
|
|
272
272
|
* (which must claim the CLI slot before this module loads) can run the exact
|
|
273
273
|
* same scan instead of duplicating it.
|
|
274
|
+
* @param {{ trace?: import("./lib/trace.mjs").TraceFn }} [opts] `trace` is where
|
|
275
|
+
* this scan announces engagement; a host with its own trace channel passes its
|
|
276
|
+
* sink so the announcement lands where its detector reads (see lib/trace.mjs).
|
|
274
277
|
* @returns {Promise<void>}
|
|
275
278
|
*/
|
|
276
|
-
export async function cliMain() {
|
|
279
|
+
export async function cliMain({ trace: sink = trace } = {}) {
|
|
280
|
+
// Bound best-effort: the announcements below run BEFORE the auto-clean and
|
|
281
|
+
// the alert write, with no catch above them, so a throwing host sink would
|
|
282
|
+
// abort the scan silently (see bestEffortTrace).
|
|
283
|
+
const emitTrace = bestEffortTrace(sink);
|
|
277
284
|
/* c8 ignore start -- fail-closed module-load guard: only reachable when the
|
|
278
285
|
agent-sanitizer import above failed, which can't be simulated in the
|
|
279
286
|
spawned-subprocess CLI run the tests observe. */
|
|
@@ -281,7 +288,7 @@ export async function cliMain() {
|
|
|
281
288
|
// Emit the engagement event with a "skipped" outcome so the loss is LOUD on
|
|
282
289
|
// the trace channel — a scan that never ran is otherwise invisible, and the
|
|
283
290
|
// downstream PreToolUse sanitize gate then passes cleanly all session.
|
|
284
|
-
|
|
291
|
+
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
285
292
|
process.stderr.write(
|
|
286
293
|
"scan-invisible-chars: agent-sanitizer failed to load (node deps not " +
|
|
287
294
|
"installed and session-setup did not finish in time); instruction " +
|
|
@@ -304,10 +311,10 @@ export async function cliMain() {
|
|
|
304
311
|
const allFindings = scanProject();
|
|
305
312
|
|
|
306
313
|
if (allFindings.length === 0) {
|
|
307
|
-
|
|
314
|
+
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "clean" });
|
|
308
315
|
return;
|
|
309
316
|
}
|
|
310
|
-
|
|
317
|
+
emitTrace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, {
|
|
311
318
|
outcome: "found",
|
|
312
319
|
files: allFindings.length,
|
|
313
320
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -167,6 +167,24 @@ export function safeErrMessage(err: unknown, cap?: number): string;
|
|
|
167
167
|
* @returns {void}
|
|
168
168
|
*/
|
|
169
169
|
export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
|
|
170
|
+
/**
|
|
171
|
+
* Adopt a host's own cold-start marker path in place of the derived one, so a
|
|
172
|
+
* host whose setup script already writes a marker under its own convention can
|
|
173
|
+
* use these hooks without running a second, disagreeing wait loop against a path
|
|
174
|
+
* nothing writes.
|
|
175
|
+
*
|
|
176
|
+
* ORDERING, same rule as {@link registerLazyModules}: call this before importing
|
|
177
|
+
* any hook module. `lib/control-plane.mjs` resolves the marker at MODULE scope,
|
|
178
|
+
* so a call that lands after that import cannot reach the wait it was meant to
|
|
179
|
+
* steer. A late call is reported on stderr rather than thrown: a throw at a
|
|
180
|
+
* bundle entry's top level kills the hook process before it writes a response,
|
|
181
|
+
* and a hook that emits nothing is read as non-blocking — the fail-OPEN this
|
|
182
|
+
* whole file is built to avoid. The late call still takes effect for every
|
|
183
|
+
* later resolution.
|
|
184
|
+
* @param {string | null} path absolute marker path, or null to restore the derivation
|
|
185
|
+
* @returns {void}
|
|
186
|
+
*/
|
|
187
|
+
export function configureHookgateMarker(path: string | null): void;
|
|
170
188
|
/**
|
|
171
189
|
* Path of the cold-start in-flight marker a host's setup script writes
|
|
172
190
|
* SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
|
|
@@ -178,7 +196,8 @@ export function emitHookResponse(hookEventName: string, fields: Record<string, u
|
|
|
178
196
|
* the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
|
|
179
197
|
* canonicalization — the two must produce byte-identical paths), so no env has
|
|
180
198
|
* to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
|
|
181
|
-
* setup ran → nothing to wait on)
|
|
199
|
+
* setup ran → nothing to wait on), or whatever a host set via
|
|
200
|
+
* {@link configureHookgateMarker}.
|
|
182
201
|
* @param {string | undefined} [projectDir]
|
|
183
202
|
* @param {string | undefined} [runtimeDir]
|
|
184
203
|
* @returns {string | null}
|
|
@@ -14,8 +14,47 @@ export function traceThreshold(env?: NodeJS.ProcessEnv): number;
|
|
|
14
14
|
* @returns {void}
|
|
15
15
|
*/
|
|
16
16
|
export function trace(event: string, fields?: Record<string, unknown>, level?: "info" | "debug"): void;
|
|
17
|
+
/**
|
|
18
|
+
* `sink` with {@link trace}'s best-effort posture forced onto it: a throw is
|
|
19
|
+
* swallowed, so an announcement can never break the hook making it.
|
|
20
|
+
*
|
|
21
|
+
* This is what makes the sink safely injectable. The announcement call sites were
|
|
22
|
+
* placed under the guarantee that emitting cannot fail, and one of them relies on
|
|
23
|
+
* it outright: scan-invisible-chars announces BEFORE it auto-cleans the
|
|
24
|
+
* contaminated instruction files and arms the PreToolUse gate, with no catch
|
|
25
|
+
* above it, so a throwing sink there would abort the scan — leaving the payload on
|
|
26
|
+
* disk, the gate un-armed, and NO announcement on any channel. The loss the
|
|
27
|
+
* announcement exists to make loud would itself be silent.
|
|
28
|
+
*
|
|
29
|
+
* Swallowing is right here and is not licence to swallow elsewhere in this tree:
|
|
30
|
+
* a dropped announcement is already loud in the host's own detector — that is what
|
|
31
|
+
* a trace channel is — whereas a killed hook is loud nowhere.
|
|
32
|
+
* @param {TraceFn} sink
|
|
33
|
+
* @returns {TraceFn}
|
|
34
|
+
*/
|
|
35
|
+
export function bestEffortTrace(sink: TraceFn): TraceFn;
|
|
36
|
+
/**
|
|
37
|
+
* The sink shape a hook emits through: the event name, its metadata fields, and
|
|
38
|
+
* the level. A host implementation receives the same {@link TraceEvent} names the
|
|
39
|
+
* default emits, so it can remap them onto its own channel's vocabulary.
|
|
40
|
+
*
|
|
41
|
+
* A sink is NOT required to be total — throw freely. Every hook binds the one it
|
|
42
|
+
* was given through {@link bestEffortTrace}, which is what upholds the channel's
|
|
43
|
+
* never-breaks-a-hook posture on host code that cannot promise it.
|
|
44
|
+
* @typedef {(event: string, fields?: Record<string, unknown>, level?: "info"|"debug") => void} TraceFn
|
|
45
|
+
*/
|
|
17
46
|
/** Trace-channel event names. */
|
|
18
47
|
export const TraceEvent: Readonly<{
|
|
19
48
|
HOOK_RAN: "hook_ran";
|
|
20
49
|
SCAN_INVISIBLE_CHARS_RAN: "scan_invisible_chars_ran";
|
|
21
50
|
}>;
|
|
51
|
+
/**
|
|
52
|
+
* The sink shape a hook emits through: the event name, its metadata fields, and
|
|
53
|
+
* the level. A host implementation receives the same {@link TraceEvent} names the
|
|
54
|
+
* default emits, so it can remap them onto its own channel's vocabulary.
|
|
55
|
+
*
|
|
56
|
+
* A sink is NOT required to be total — throw freely. Every hook binds the one it
|
|
57
|
+
* was given through {@link bestEffortTrace}, which is what upholds the channel's
|
|
58
|
+
* never-breaks-a-hook posture on host code that cannot promise it.
|
|
59
|
+
*/
|
|
60
|
+
export type TraceFn = (event: string, fields?: Record<string, unknown>, level?: "info" | "debug") => void;
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
7
7
|
* injectable for tests; the default binds the real redactor-daemon io (the
|
|
8
8
|
* layer reads the target file and maps secrets through the daemon)
|
|
9
|
+
* @param {import("./lib/trace.mjs").TraceFn} [sink] where engagement is
|
|
10
|
+
* announced; a host with its own trace channel passes its sink (see lib/trace.mjs)
|
|
9
11
|
* @returns {Promise<Record<string, unknown> | null>}
|
|
10
12
|
*/
|
|
11
|
-
export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted
|
|
13
|
+
export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, sink?: import("./lib/trace.mjs").TraceFn): Promise<Record<string, unknown> | null>;
|
|
12
14
|
/**
|
|
13
15
|
* Agent-agnostic judge over the four protections: consumes a control-plane
|
|
14
16
|
* ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
|
|
@@ -19,13 +21,18 @@ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, t
|
|
|
19
21
|
* fail-closed posture holds even when the adapter never loaded.
|
|
20
22
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
21
23
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
22
|
-
* @param {{
|
|
24
|
+
* @param {{
|
|
25
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
26
|
+
* gates?: HostGate[],
|
|
27
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
28
|
+
* }} [opts]
|
|
23
29
|
* messages are merged over the defaults, so a partial table is supported
|
|
24
30
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
25
31
|
*/
|
|
26
32
|
export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, opts?: {
|
|
27
33
|
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
28
34
|
gates?: HostGate[];
|
|
35
|
+
trace?: import("./lib/trace.mjs").TraceFn;
|
|
29
36
|
}): Promise<import("agent-control-plane-core").Verdict>;
|
|
30
37
|
/**
|
|
31
38
|
* The dependency-load failure hiding behind a hook error, or "". A binding that
|
|
@@ -67,14 +74,14 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
|
|
|
67
74
|
* @param {{
|
|
68
75
|
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
69
76
|
* gates?: HostGate[],
|
|
70
|
-
*
|
|
77
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
71
78
|
* }} [opts]
|
|
72
79
|
* @returns {Promise<void>}
|
|
73
80
|
*/
|
|
74
81
|
export function cliMain(opts?: {
|
|
75
82
|
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
76
83
|
gates?: HostGate[];
|
|
77
|
-
|
|
84
|
+
trace?: import("./lib/trace.mjs").TraceFn;
|
|
78
85
|
}): Promise<void>;
|
|
79
86
|
/**
|
|
80
87
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
@@ -93,12 +100,14 @@ export function cliMain(opts?: {
|
|
|
93
100
|
* unknownEvent: string,
|
|
94
101
|
* failed: (cause: string) => string,
|
|
95
102
|
* unparsable: (cause: string) => string,
|
|
103
|
+
* remedy: string,
|
|
96
104
|
* }>}
|
|
97
105
|
*/
|
|
98
106
|
export const PRE_TOOL_USE_MESSAGES: Readonly<{
|
|
99
107
|
unknownEvent: string;
|
|
100
108
|
failed: (cause: string) => string;
|
|
101
109
|
unparsable: (cause: string) => string;
|
|
110
|
+
remedy: string;
|
|
102
111
|
}>;
|
|
103
112
|
/**
|
|
104
113
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
@@ -27,9 +27,22 @@
|
|
|
27
27
|
* @property {(raw: string) => string | undefined} [redactNote]
|
|
28
28
|
* Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
|
|
29
29
|
* appended to that leaf's "API keys/secrets redacted: …" warning.
|
|
30
|
-
* @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
30
|
+
* @property {(record: { tool: string | null, session_id?: string, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
31
31
|
* Awaited once per judged event that carried a tool response, with the output
|
|
32
|
-
* the model will actually see.
|
|
32
|
+
* the model will actually see. `session_id` is the harness's session identity,
|
|
33
|
+
* lifted from the event's `meta` — an audit trail that cannot say WHICH session
|
|
34
|
+
* produced a record cannot be read back per-session, and the tool fields alone
|
|
35
|
+
* do not carry it. Absent when the payload omitted it.
|
|
36
|
+
* @property {import("./lib/trace.mjs").TraceFn} [trace]
|
|
37
|
+
* Where this hook announces engagement. A host that already runs a trace
|
|
38
|
+
* channel under its own environment variables passes its sink here, so the
|
|
39
|
+
* announcement lands where its detector reads instead of on this package's
|
|
40
|
+
* channel. Defaults to lib/trace.mjs's `trace`.
|
|
41
|
+
* @property {string} [remedy]
|
|
42
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
43
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
44
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
45
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
33
46
|
*/
|
|
34
47
|
/**
|
|
35
48
|
* Run Layers 1-4 over a single text blob, delegated to the package's output seam
|
|
@@ -142,9 +155,10 @@ export function failClosedContext(depsLoaded?: () => boolean, remedy?: string):
|
|
|
142
155
|
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
143
156
|
* @param {string} message
|
|
144
157
|
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
158
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
145
159
|
* @returns {void}
|
|
146
160
|
*/
|
|
147
|
-
export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void): void;
|
|
161
|
+
export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void, remedy?: string): void;
|
|
148
162
|
/**
|
|
149
163
|
* Run the sanitization pipeline over a tool output and return the contract-
|
|
150
164
|
* shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
|
|
@@ -258,12 +272,30 @@ export type SanitizeExtensions = {
|
|
|
258
272
|
redactNote?: ((raw: string) => string | undefined) | undefined;
|
|
259
273
|
/**
|
|
260
274
|
* Awaited once per judged event that carried a tool response, with the output
|
|
261
|
-
* the model will actually see.
|
|
275
|
+
* the model will actually see. `session_id` is the harness's session identity,
|
|
276
|
+
* lifted from the event's `meta` — an audit trail that cannot say WHICH session
|
|
277
|
+
* produced a record cannot be read back per-session, and the tool fields alone
|
|
278
|
+
* do not carry it. Absent when the payload omitted it.
|
|
262
279
|
*/
|
|
263
280
|
audit?: ((record: {
|
|
264
281
|
tool: string | null;
|
|
282
|
+
session_id?: string;
|
|
265
283
|
modified: boolean;
|
|
266
284
|
output: unknown;
|
|
267
285
|
context?: string;
|
|
268
286
|
}) => Promise<void> | void) | undefined;
|
|
287
|
+
/**
|
|
288
|
+
* Where this hook announces engagement. A host that already runs a trace
|
|
289
|
+
* channel under its own environment variables passes its sink here, so the
|
|
290
|
+
* announcement lands where its detector reads instead of on this package's
|
|
291
|
+
* channel. Defaults to lib/trace.mjs's `trace`.
|
|
292
|
+
*/
|
|
293
|
+
trace?: import("./lib/trace.mjs").TraceFn | undefined;
|
|
294
|
+
/**
|
|
295
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
296
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
297
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
298
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
299
|
+
*/
|
|
300
|
+
remedy?: string | undefined;
|
|
269
301
|
};
|
|
@@ -14,15 +14,28 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core").ToolCallEvent, strip?: ((s: string) => string) | null, overrides?: Partial<typeof USER_PROMPT_MESSAGES>): import("agent-control-plane-core").Verdict;
|
|
16
16
|
/**
|
|
17
|
+
* `read` and `write` stay positional — every caller supplies both — while the
|
|
18
|
+
* injectable seams ride in one bag, so a host supplying only the last of them does
|
|
19
|
+
* not have to pass `undefined` for the others.
|
|
17
20
|
* @param {() => Promise<any> | any} read
|
|
18
21
|
* @param {(chunk: string) => void} write
|
|
19
|
-
* @param {
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
* @param {{
|
|
23
|
+
* strip?: ((s: string) => string) | null,
|
|
24
|
+
* overrides?: Partial<typeof USER_PROMPT_MESSAGES>,
|
|
25
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
26
|
+
* }} [opts]
|
|
27
|
+
* `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
|
|
28
|
+
* injectable so the fail-closed path is testable); `overrides` are reason
|
|
29
|
+
* overrides, merged over the defaults so a partial table can never leave a field
|
|
30
|
+
* unset; `trace` is where engagement is announced, for a host with its own trace
|
|
31
|
+
* channel (see lib/trace.mjs).
|
|
23
32
|
* @returns {Promise<void>}
|
|
24
33
|
*/
|
|
25
|
-
export function main(read: () => Promise<any> | any, write: (chunk: string) => void,
|
|
34
|
+
export function main(read: () => Promise<any> | any, write: (chunk: string) => void, opts?: {
|
|
35
|
+
strip?: ((s: string) => string) | null;
|
|
36
|
+
overrides?: Partial<typeof USER_PROMPT_MESSAGES>;
|
|
37
|
+
trace?: import("./lib/trace.mjs").TraceFn;
|
|
38
|
+
}): Promise<void>;
|
|
26
39
|
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
27
40
|
export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPrompt;
|
|
28
41
|
/**
|
|
@@ -36,6 +49,7 @@ export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPromp
|
|
|
36
49
|
* blockContext: string,
|
|
37
50
|
* sgrNote: string,
|
|
38
51
|
* hookFailed: (cause: string) => string,
|
|
52
|
+
* remedy: string,
|
|
39
53
|
* }>}
|
|
40
54
|
*/
|
|
41
55
|
export const USER_PROMPT_MESSAGES: Readonly<{
|
|
@@ -43,4 +57,5 @@ export const USER_PROMPT_MESSAGES: Readonly<{
|
|
|
43
57
|
blockContext: string;
|
|
44
58
|
sgrNote: string;
|
|
45
59
|
hookFailed: (cause: string) => string;
|
|
60
|
+
remedy: string;
|
|
46
61
|
}>;
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
* the alert for the PreToolUse gate otherwise. Exported so a bundle entry
|
|
4
4
|
* (which must claim the CLI slot before this module loads) can run the exact
|
|
5
5
|
* same scan instead of duplicating it.
|
|
6
|
+
* @param {{ trace?: import("./lib/trace.mjs").TraceFn }} [opts] `trace` is where
|
|
7
|
+
* this scan announces engagement; a host with its own trace channel passes its
|
|
8
|
+
* sink so the announcement lands where its detector reads (see lib/trace.mjs).
|
|
6
9
|
* @returns {Promise<void>}
|
|
7
10
|
*/
|
|
8
|
-
export function cliMain(
|
|
11
|
+
export function cliMain({ trace: sink }?: {
|
|
12
|
+
trace?: import("./lib/trace.mjs").TraceFn;
|
|
13
|
+
}): Promise<void>;
|
|
9
14
|
/**
|
|
10
15
|
* @param {string} run
|
|
11
16
|
* @returns {{ method: string, decoded: string }}
|