agent-sanitizer 2.9.1 → 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/pretooluse-sanitize.mjs +27 -9
- package/claude-hooks/sanitize-output.mjs +19 -4
- package/claude-hooks/sanitize-user-prompt.mjs +22 -12
- 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 +11 -2
- package/types/claude-hooks/sanitize-output.d.mts +22 -3
- package/types/claude-hooks/sanitize-user-prompt.d.mts +18 -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
|
+
}
|
|
@@ -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
|
|
|
@@ -150,18 +150,19 @@ const defaultRehydrate = (tool, toolInput) =>
|
|
|
150
150
|
* it unchanged. The trace lives on this in-process, mutation-tested path, not in
|
|
151
151
|
* the CLI block, so engagement is announced (hook_ran — metadata only: hook
|
|
152
152
|
* name, tool, outcome) for every exit.
|
|
153
|
+
* @param {import("./lib/trace.mjs").TraceFn} emitTrace
|
|
153
154
|
* @param {string} toolName
|
|
154
155
|
* @param {Record<string, unknown> | null} fields
|
|
155
156
|
* @returns {Record<string, unknown> | null}
|
|
156
157
|
*/
|
|
157
|
-
function emitTraced(toolName, fields) {
|
|
158
|
+
function emitTraced(emitTrace, toolName, fields) {
|
|
158
159
|
let outcome = "modified";
|
|
159
160
|
if (fields === null) outcome = "noop";
|
|
160
161
|
else if (fields.permissionDecision === PermissionDecision.DENY)
|
|
161
162
|
outcome = "deny";
|
|
162
163
|
else if (fields.permissionDecision === PermissionDecision.ASK)
|
|
163
164
|
outcome = "ask";
|
|
164
|
-
|
|
165
|
+
emitTrace(TraceEvent.HOOK_RAN, { hook: HOOK_NAME, tool: toolName, outcome });
|
|
165
166
|
return fields;
|
|
166
167
|
}
|
|
167
168
|
|
|
@@ -173,12 +174,18 @@ function emitTraced(toolName, fields) {
|
|
|
173
174
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
174
175
|
* injectable for tests; the default binds the real redactor-daemon io (the
|
|
175
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)
|
|
176
179
|
* @returns {Promise<Record<string, unknown> | null>}
|
|
177
180
|
*/
|
|
178
181
|
export async function buildPreToolUseResponse(
|
|
179
182
|
input,
|
|
180
183
|
rehydrate = defaultRehydrate,
|
|
184
|
+
sink = trace,
|
|
181
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);
|
|
182
189
|
const asks = [];
|
|
183
190
|
const contexts = [];
|
|
184
191
|
|
|
@@ -227,7 +234,7 @@ export async function buildPreToolUseResponse(
|
|
|
227
234
|
// above, so it returns immediately.
|
|
228
235
|
const rehydrated = await rehydrate(tool, current);
|
|
229
236
|
if (rehydrated && "deny" in rehydrated)
|
|
230
|
-
return emitTraced(input.tool_name, {
|
|
237
|
+
return emitTraced(emitTrace, input.tool_name, {
|
|
231
238
|
permissionDecision: PermissionDecision.DENY,
|
|
232
239
|
permissionDecisionReason: rehydrated.deny,
|
|
233
240
|
});
|
|
@@ -238,6 +245,7 @@ export async function buildPreToolUseResponse(
|
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
return emitTraced(
|
|
248
|
+
emitTrace,
|
|
241
249
|
input.tool_name,
|
|
242
250
|
assembleResponse({ changed, current, asks, contexts, pendingGateAck }),
|
|
243
251
|
);
|
|
@@ -290,12 +298,16 @@ function assembleResponse({
|
|
|
290
298
|
* fail-closed posture holds even when the adapter never loaded.
|
|
291
299
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
292
300
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
293
|
-
* @param {{
|
|
301
|
+
* @param {{
|
|
302
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
303
|
+
* gates?: HostGate[],
|
|
304
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
305
|
+
* }} [opts]
|
|
294
306
|
* messages are merged over the defaults, so a partial table is supported
|
|
295
307
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
296
308
|
*/
|
|
297
309
|
export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
298
|
-
const { gates = [] } = opts;
|
|
310
|
+
const { gates = [], trace: emitTrace = trace } = opts;
|
|
299
311
|
// MERGED over the defaults, never substituted for them. A host that overrides
|
|
300
312
|
// one field would otherwise leave the rest undefined, and the miss lands in
|
|
301
313
|
// the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
|
|
@@ -327,7 +339,7 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
|
327
339
|
const denyReason = gate(input);
|
|
328
340
|
if (denyReason) return { decision: Decision.DENY, reason: denyReason };
|
|
329
341
|
}
|
|
330
|
-
const fields = await buildPreToolUseResponse(input, rehydrate);
|
|
342
|
+
const fields = await buildPreToolUseResponse(input, rehydrate, emitTrace);
|
|
331
343
|
if (fields === null) return { decision: Decision.ALLOW };
|
|
332
344
|
/** @type {Record<string, unknown>} */
|
|
333
345
|
const verdict = {
|
|
@@ -419,15 +431,21 @@ export function failClosedFields(parsedOk, err, opts = {}) {
|
|
|
419
431
|
* @param {{
|
|
420
432
|
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
421
433
|
* gates?: HostGate[],
|
|
434
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
422
435
|
* }} [opts]
|
|
423
436
|
* @returns {Promise<void>}
|
|
424
437
|
*/
|
|
425
438
|
export async function cliMain(opts = {}) {
|
|
426
|
-
const { gates = [] } = opts;
|
|
439
|
+
const { gates = [], trace: emitTrace = trace } = opts;
|
|
427
440
|
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
428
441
|
await runJudgeCli(
|
|
429
442
|
HOOK_NAME,
|
|
430
|
-
(event) =>
|
|
443
|
+
(event) =>
|
|
444
|
+
judgePreToolUseSanitize(event, undefined, {
|
|
445
|
+
messages,
|
|
446
|
+
gates,
|
|
447
|
+
trace: emitTrace,
|
|
448
|
+
}),
|
|
431
449
|
{
|
|
432
450
|
// Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
|
|
433
451
|
// hard-denies (adversary-inducible, no benefit to failing); any throw
|
|
@@ -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,17 @@ 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`.
|
|
198
206
|
* @property {string} [remedy]
|
|
199
207
|
* What a reader should run when the sanitizer's own bindings are what is
|
|
200
208
|
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
@@ -572,13 +580,16 @@ export function emitFailClosed(
|
|
|
572
580
|
* @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
|
|
573
581
|
*/
|
|
574
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);
|
|
575
586
|
/**
|
|
576
587
|
* @param {string} outcome noop | clean | flagged | modified
|
|
577
588
|
* @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
|
|
578
589
|
* @returns {{ mutated_output?: unknown, additional_context?: string } | null}
|
|
579
590
|
*/
|
|
580
591
|
const emit = (outcome, fields) => {
|
|
581
|
-
|
|
592
|
+
emitTrace(TraceEvent.HOOK_RAN, {
|
|
582
593
|
hook: HOOK_NAME,
|
|
583
594
|
tool: input.tool_name,
|
|
584
595
|
outcome,
|
|
@@ -716,6 +727,10 @@ export async function judgeSanitizeOutput(event, ext = {}) {
|
|
|
716
727
|
const modified = fields !== null && Object.hasOwn(fields, "mutated_output");
|
|
717
728
|
await ext.audit({
|
|
718
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,
|
|
719
734
|
modified,
|
|
720
735
|
output: modified ? fields?.mutated_output : event.response,
|
|
721
736
|
context: fields?.additional_context,
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
28
28
|
} from "./lib/hook-io.mjs";
|
|
29
29
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
30
|
-
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
30
|
+
import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
|
|
31
31
|
// classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
|
|
32
32
|
// come from the agent-sanitizer package. They are bound by a *caught* dynamic
|
|
33
33
|
// import, never a bare top-level `import … from "…"`: a static npm import
|
|
@@ -159,20 +159,30 @@ export function judgeSanitizeUserPrompt(
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
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.
|
|
162
165
|
* @param {() => Promise<any> | any} read
|
|
163
166
|
* @param {(chunk: string) => void} write
|
|
164
|
-
* @param {
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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).
|
|
168
177
|
* @returns {Promise<void>}
|
|
169
178
|
*/
|
|
170
|
-
export async function main(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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);
|
|
176
186
|
// Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
|
|
177
187
|
// call site where a missing field would throw out of the catch and fail OPEN.
|
|
178
188
|
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
@@ -189,7 +199,7 @@ export async function main(
|
|
|
189
199
|
const verdict = judgeSanitizeUserPrompt(event, strip, messages);
|
|
190
200
|
// Announce engagement on the trace channel like the other stdin hooks —
|
|
191
201
|
// a prompt gate that silently stopped running is otherwise invisible.
|
|
192
|
-
|
|
202
|
+
emitTrace(TraceEvent.HOOK_RAN, {
|
|
193
203
|
hook: "sanitize-user-prompt",
|
|
194
204
|
outcome:
|
|
195
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,12 +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[],
|
|
77
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
70
78
|
* }} [opts]
|
|
71
79
|
* @returns {Promise<void>}
|
|
72
80
|
*/
|
|
73
81
|
export function cliMain(opts?: {
|
|
74
82
|
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
75
83
|
gates?: HostGate[];
|
|
84
|
+
trace?: import("./lib/trace.mjs").TraceFn;
|
|
76
85
|
}): Promise<void>;
|
|
77
86
|
/**
|
|
78
87
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
@@ -27,9 +27,17 @@
|
|
|
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`.
|
|
33
41
|
* @property {string} [remedy]
|
|
34
42
|
* What a reader should run when the sanitizer's own bindings are what is
|
|
35
43
|
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
@@ -264,14 +272,25 @@ export type SanitizeExtensions = {
|
|
|
264
272
|
redactNote?: ((raw: string) => string | undefined) | undefined;
|
|
265
273
|
/**
|
|
266
274
|
* Awaited once per judged event that carried a tool response, with the output
|
|
267
|
-
* 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.
|
|
268
279
|
*/
|
|
269
280
|
audit?: ((record: {
|
|
270
281
|
tool: string | null;
|
|
282
|
+
session_id?: string;
|
|
271
283
|
modified: boolean;
|
|
272
284
|
output: unknown;
|
|
273
285
|
context?: string;
|
|
274
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;
|
|
275
294
|
/**
|
|
276
295
|
* What a reader should run when the sanitizer's own bindings are what is
|
|
277
296
|
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
@@ -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
|
/**
|
|
@@ -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 }}
|