agent-sanitizer 2.58.10 → 2.58.11
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/THREAT-MODEL.md +12 -7
- package/bin/sanitize-cli.mjs +4 -17
- package/claude-hooks/lib/authored-content.mjs +3 -2
- package/claude-hooks/lib/env-config.mjs +12 -10
- package/claude-hooks/lib/hook-fault.mjs +38 -5
- package/claude-hooks/lib/hook-io.mjs +133 -54
- package/claude-hooks/lib/invisible-alert.mjs +9 -7
- package/claude-hooks/lib/knobs.mjs +286 -0
- package/claude-hooks/lib/redactor-client.mjs +15 -33
- package/claude-hooks/lib/reveal.mjs +5 -10
- package/claude-hooks/lib/trace.mjs +38 -9
- package/claude-hooks/plugin-hooks.mjs +2 -0
- package/claude-hooks/pretooluse-sanitize.mjs +4 -1
- package/claude-hooks/sanitize-output.mjs +37 -21
- package/claude-hooks/sanitize-user-prompt.mjs +2 -0
- package/claude-hooks/scan-invisible-chars.mjs +2 -0
- package/claude-hooks/scan-loaded-instructions.mjs +2 -0
- package/package.json +1 -1
- package/src/instructions.mjs +6 -1
- package/types/claude-hooks/lib/env-config.d.mts +8 -7
- package/types/claude-hooks/lib/hook-fault.d.mts +74 -4
- package/types/claude-hooks/lib/hook-io.d.mts +55 -14
- package/types/claude-hooks/lib/knobs.d.mts +138 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +3 -15
- package/types/claude-hooks/lib/trace.d.mts +19 -4
- package/types/claude-hooks/sanitize-output.d.mts +5 -6
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "./lib/hook-io.mjs";
|
|
29
29
|
import {
|
|
30
30
|
registerFaultPolicy,
|
|
31
|
+
KNOB_DECIDES,
|
|
31
32
|
hookFaultOutcome,
|
|
32
33
|
writeFaultOutcome,
|
|
33
34
|
} from "./lib/hook-fault.mjs";
|
|
@@ -85,6 +86,7 @@ const HOOK_NAME = "sanitize-user-prompt";
|
|
|
85
86
|
registerFaultPolicy(HOOK_NAME, {
|
|
86
87
|
event: HookEvent.USER_PROMPT_SUBMIT,
|
|
87
88
|
guarded: "prompt",
|
|
89
|
+
alwaysClosed: KNOB_DECIDES,
|
|
88
90
|
closed: (ctx) => ({
|
|
89
91
|
envelope: {
|
|
90
92
|
decision: "block",
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./lib/hook-io.mjs";
|
|
40
40
|
import {
|
|
41
41
|
registerFaultPolicy,
|
|
42
|
+
KNOB_DECIDES,
|
|
42
43
|
hookFaultOutcome,
|
|
43
44
|
writeFaultOutcome,
|
|
44
45
|
} from "./lib/hook-fault.mjs";
|
|
@@ -163,6 +164,7 @@ function faultLine(ctx) {
|
|
|
163
164
|
registerFaultPolicy(HOOK_NAME, {
|
|
164
165
|
event: HookEvent.SESSION_START,
|
|
165
166
|
guarded: "instruction files",
|
|
167
|
+
alwaysClosed: KNOB_DECIDES,
|
|
166
168
|
open: (ctx) => ({
|
|
167
169
|
stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
|
|
168
170
|
exitCode: 1,
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "./lib/hook-io.mjs";
|
|
33
33
|
import {
|
|
34
34
|
registerFaultPolicy,
|
|
35
|
+
KNOB_DECIDES,
|
|
35
36
|
hookFaultOutcome,
|
|
36
37
|
writeFaultOutcome,
|
|
37
38
|
} from "./lib/hook-fault.mjs";
|
|
@@ -121,6 +122,7 @@ function emptyPayloadParts(ctx) {
|
|
|
121
122
|
registerFaultPolicy(HOOK_NAME, {
|
|
122
123
|
event: HookEvent.INSTRUCTIONS_LOADED,
|
|
123
124
|
guarded: "a loaded instruction file",
|
|
125
|
+
alwaysClosed: KNOB_DECIDES,
|
|
124
126
|
open: (ctx) =>
|
|
125
127
|
emptyPayloadParts(ctx) ?? {
|
|
126
128
|
stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.58.
|
|
3
|
+
"version": "2.58.11",
|
|
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": {
|
package/src/instructions.mjs
CHANGED
|
@@ -362,8 +362,13 @@ function keepContained(absPath, realRoot, literalRoot, pattern) {
|
|
|
362
362
|
*/
|
|
363
363
|
export function walkContextGlobs(globs, cwd, exclude) {
|
|
364
364
|
const root = resolve(cwd);
|
|
365
|
+
// Resolved against `root`, never joined: for a literal path segment (the
|
|
366
|
+
// `.claude` of `.claude/*.md`) Node hands `exclude` a Dirent whose
|
|
367
|
+
// parentPath is RELATIVE to the glob's cwd, and `relative()` would resolve
|
|
368
|
+
// it against process.cwd() — pruning `.claude` whenever the process runs
|
|
369
|
+
// under a `.claude/worktrees/` checkout.
|
|
365
370
|
/** @param {import("node:fs").Dirent} dirent @returns {string} */
|
|
366
|
-
const absolute = (dirent) =>
|
|
371
|
+
const absolute = (dirent) => resolve(root, dirent.parentPath, dirent.name);
|
|
367
372
|
return globSync(globs, {
|
|
368
373
|
cwd,
|
|
369
374
|
withFileTypes: true,
|
|
@@ -59,18 +59,19 @@ export function dynamicSecretVars(env?: Record<string, string | undefined>): str
|
|
|
59
59
|
*/
|
|
60
60
|
export function extraSecretVars(env?: Record<string, string | undefined>): string[];
|
|
61
61
|
/**
|
|
62
|
-
* True when the operator opted into the secret-redaction layer.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* True when the operator opted into the secret-redaction layer. Only `1`
|
|
63
|
+
* enables it, matching the other public switches (`AGENT_SANITIZER_*_DISABLED`)
|
|
64
|
+
* and the SessionStart provisioning gate in provision-redactor.sh, so the hooks
|
|
65
|
+
* never redact through an engine that was not provisioned. Any other value
|
|
66
|
+
* keeps the layer off — reported, so `true` is not silently a no-op.
|
|
66
67
|
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
67
68
|
* @returns {boolean}
|
|
68
69
|
*/
|
|
69
70
|
export function secretsEnabled(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): boolean;
|
|
70
71
|
/**
|
|
71
|
-
* True when the operator opted into flagging digest-shaped URL values.
|
|
72
|
-
* for the reason {@link secretsEnabled} gives: a typo fails toward the
|
|
73
|
-
* which is the exemption the precision rule prefers.
|
|
72
|
+
* True when the operator opted into flagging digest-shaped URL values. Only
|
|
73
|
+
* `1`, for the reason {@link secretsEnabled} gives: a typo fails toward the
|
|
74
|
+
* default (reported), which is the exemption the precision rule prefers.
|
|
74
75
|
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
75
76
|
* @returns {boolean}
|
|
76
77
|
*/
|
|
@@ -32,7 +32,8 @@ export function defaultOpen(ctx: FaultContext): FaultParts;
|
|
|
32
32
|
/**
|
|
33
33
|
* Resolve `hook`'s response to its own failure under the caller's posture. This
|
|
34
34
|
* is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
|
|
35
|
-
* knob cannot be honored in three hooks and skipped in the fourth
|
|
35
|
+
* knob cannot be honored in three hooks and skipped in the fourth — and the
|
|
36
|
+
* only place a policy's `alwaysClosed` can override it.
|
|
36
37
|
* @param {string} hook
|
|
37
38
|
* @param {unknown} err
|
|
38
39
|
* @param {Record<string, any> & {
|
|
@@ -64,6 +65,63 @@ export function writeFaultOutcome(outcome: FaultOutcome, write?: (chunk: string)
|
|
|
64
65
|
* @type {readonly string[]}
|
|
65
66
|
*/
|
|
66
67
|
export const FAULT_POLICY_HOOKS: readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* What a hook's fault renders to. `fields`/`envelope` are the stdout response
|
|
70
|
+
* (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
|
|
71
|
+
* `envelope` is a hook that answers with a top-level shape instead, like
|
|
72
|
+
* UserPromptSubmit's `{decision:"block"}`); `stderr` and `exitCode` are the
|
|
73
|
+
* process-level halves a hook with no stdout channel uses; `armAlert` asks the
|
|
74
|
+
* caller to persist its cross-hook alert so a LATER gate carries the closed
|
|
75
|
+
* posture the faulting hook could not express itself.
|
|
76
|
+
* @typedef {{
|
|
77
|
+
* posture: "open" | "closed",
|
|
78
|
+
* fields: Record<string, unknown> | null,
|
|
79
|
+
* fallbackFields: Record<string, unknown> | null,
|
|
80
|
+
* envelope: object | null,
|
|
81
|
+
* stderr: string | null,
|
|
82
|
+
* exitCode: number,
|
|
83
|
+
* armAlert: boolean,
|
|
84
|
+
* }} FaultOutcome
|
|
85
|
+
*/
|
|
86
|
+
/**
|
|
87
|
+
* What a policy's `open`/`closed` builder returns: any subset of a
|
|
88
|
+
* {@link FaultOutcome}'s renderable slots. Everything omitted takes its default
|
|
89
|
+
* (no output, exit 0, no alert).
|
|
90
|
+
* @typedef {{
|
|
91
|
+
* fields?: Record<string, unknown>,
|
|
92
|
+
* fallbackFields?: Record<string, unknown>,
|
|
93
|
+
* envelope?: object,
|
|
94
|
+
* stderr?: string,
|
|
95
|
+
* exitCode?: number,
|
|
96
|
+
* armAlert?: boolean,
|
|
97
|
+
* }} FaultParts
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* The context a builder is handed: the caller's own inputs (whatever it passed
|
|
101
|
+
* to {@link hookFaultOutcome}) plus the values every hook derived by hand
|
|
102
|
+
* before — the hook name, the scrubbed error message, and the model-facing
|
|
103
|
+
* open-posture warning — and `pinnedClosed`, true when the policy's
|
|
104
|
+
* `alwaysClosed` overrode an open knob, so the closed arm can say why.
|
|
105
|
+
* @typedef {Record<string, any> & {
|
|
106
|
+
* hook: string,
|
|
107
|
+
* err: unknown,
|
|
108
|
+
* message: string,
|
|
109
|
+
* openContext: string,
|
|
110
|
+
* pinnedClosed: boolean,
|
|
111
|
+
* }} FaultContext
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* Whether a hook's faults are withheld whatever AGENT_SANITIZER_FAIL_OPEN says,
|
|
115
|
+
* for the caller's environment.
|
|
116
|
+
* @typedef {(
|
|
117
|
+
* env: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined,
|
|
118
|
+
* ) => boolean} AlwaysClosed
|
|
119
|
+
*/
|
|
120
|
+
/**
|
|
121
|
+
* The `alwaysClosed` of a hook whose posture the knob alone decides.
|
|
122
|
+
* @type {AlwaysClosed}
|
|
123
|
+
*/
|
|
124
|
+
export const KNOB_DECIDES: AlwaysClosed;
|
|
67
125
|
/**
|
|
68
126
|
* What a hook's fault renders to. `fields`/`envelope` are the stdout response
|
|
69
127
|
* (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
|
|
@@ -97,22 +155,34 @@ export type FaultParts = {
|
|
|
97
155
|
};
|
|
98
156
|
/**
|
|
99
157
|
* The context a builder is handed: the caller's own inputs (whatever it passed
|
|
100
|
-
* to {@link hookFaultOutcome}) plus the
|
|
158
|
+
* to {@link hookFaultOutcome}) plus the values every hook derived by hand
|
|
101
159
|
* before — the hook name, the scrubbed error message, and the model-facing
|
|
102
|
-
* open-posture warning
|
|
160
|
+
* open-posture warning — and `pinnedClosed`, true when the policy's
|
|
161
|
+
* `alwaysClosed` overrode an open knob, so the closed arm can say why.
|
|
103
162
|
*/
|
|
104
163
|
export type FaultContext = Record<string, any> & {
|
|
105
164
|
hook: string;
|
|
106
165
|
err: unknown;
|
|
107
166
|
message: string;
|
|
108
167
|
openContext: string;
|
|
168
|
+
pinnedClosed: boolean;
|
|
109
169
|
};
|
|
110
170
|
/**
|
|
111
|
-
*
|
|
171
|
+
* Whether a hook's faults are withheld whatever AGENT_SANITIZER_FAIL_OPEN says,
|
|
172
|
+
* for the caller's environment.
|
|
173
|
+
*/
|
|
174
|
+
export type AlwaysClosed = (env: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined) => boolean;
|
|
175
|
+
/**
|
|
176
|
+
* A hook's declared posture. `alwaysClosed` is REQUIRED, so every hook states
|
|
177
|
+
* on the record whether the knob can open it: {@link KNOB_DECIDES}, or a
|
|
178
|
+
* predicate that pins the CLOSED arm — how a guarantee the docs make
|
|
179
|
+
* unconditionally (Layer 4 withholds what it could not vet) is enforced in the
|
|
180
|
+
* one posture resolver rather than by a hook re-reading the knob.
|
|
112
181
|
*/
|
|
113
182
|
export type FaultPolicy = {
|
|
114
183
|
event: string | null;
|
|
115
184
|
guarded: string;
|
|
185
|
+
alwaysClosed: AlwaysClosed;
|
|
116
186
|
open?: (ctx: FaultContext) => FaultParts;
|
|
117
187
|
closed: (ctx: FaultContext) => FaultParts;
|
|
118
188
|
};
|
|
@@ -80,7 +80,8 @@ export function readFlag(argv: string[], name: string): string | undefined;
|
|
|
80
80
|
* mistake is reversed: those default to the safe side, so an unrecognized value
|
|
81
81
|
* there costs nothing, while here it leaves an operator who asked for
|
|
82
82
|
* strictness without it. `"false"` is the one spelling reached for by reflex,
|
|
83
|
-
* so it is honored; everything else (`""`, `"no"`, `"off"`) is the open posture
|
|
83
|
+
* so it is honored; everything else (`""`, `"no"`, `"off"`) is the open posture,
|
|
84
|
+
* and a value that is neither a closed nor an open spelling is reported.
|
|
84
85
|
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
85
86
|
* @returns {boolean}
|
|
86
87
|
*/
|
|
@@ -423,6 +424,58 @@ export function awaitLazyDependency({ tryImport, markerPresent, setupAlive, now,
|
|
|
423
424
|
* @returns {boolean}
|
|
424
425
|
*/
|
|
425
426
|
export function markerIsTrusted(path: string | null): boolean;
|
|
427
|
+
/**
|
|
428
|
+
* This process's real uid, or null on a platform with no POSIX uid model.
|
|
429
|
+
*
|
|
430
|
+
* `process.getuid()` and never `os.userInfo().uid`: userInfo() resolves the
|
|
431
|
+
* uid through the passwd database and THROWS (ENOENT) when the uid has no entry
|
|
432
|
+
* — the ordinary state of a container started with `--user 54321` — so every
|
|
433
|
+
* ownership check built on it crashed the hook that asked, and a crashed hook
|
|
434
|
+
* is non-blocking. The uid is a kernel fact; the passwd name is not needed.
|
|
435
|
+
*
|
|
436
|
+
* Null is explicit rather than the -1 userInfo() reports on Windows: callers
|
|
437
|
+
* test ownership through {@link ownedByCurrentUser}, which reads null as "cannot
|
|
438
|
+
* prove ownership" — the untrusted side every caller already handles.
|
|
439
|
+
* @returns {number | null}
|
|
440
|
+
*/
|
|
441
|
+
export function currentUid(): number | null;
|
|
442
|
+
/**
|
|
443
|
+
* Whether `st` (an lstat result) is owned by this process's uid. THE ownership
|
|
444
|
+
* predicate for every $TMPDIR store these hooks trust — a second spelling is how
|
|
445
|
+
* four call sites came to share the passwd-lookup crash {@link currentUid}
|
|
446
|
+
* documents. False when the platform has no uid model: ownership that cannot be
|
|
447
|
+
* proven is not granted. `uid` is a seam for a test that must model another
|
|
448
|
+
* uid's file; production takes the default.
|
|
449
|
+
* @param {{ uid: number }} st
|
|
450
|
+
* @param {number | null} [uid]
|
|
451
|
+
* @returns {boolean}
|
|
452
|
+
*/
|
|
453
|
+
export function ownedByCurrentUser(st: {
|
|
454
|
+
uid: number;
|
|
455
|
+
}, uid?: number | null): boolean;
|
|
456
|
+
/**
|
|
457
|
+
* Whether `st` (an lstat result) is a directory no untrusted uid can change: a
|
|
458
|
+
* real directory (lstat, so a planted symlink is not one), owned by this uid —
|
|
459
|
+
* or by root where `rootMayOwn` — with no group/other write bit. THE trust test
|
|
460
|
+
* for every private directory these hooks keep in $TMPDIR. Write is the
|
|
461
|
+
* property because creating, unlinking or replacing an entry needs write on its
|
|
462
|
+
* parent: a co-tenant holding it can delete the finding a gate needs, swap a
|
|
463
|
+
* reveal file, or rebind the redactor socket to a listener that redacts nothing.
|
|
464
|
+
* Root ownership is strictly stronger than ours, so a deployment may hand a dir
|
|
465
|
+
* to root (`rootMayOwn`); with no uid model nothing is trusted, root included.
|
|
466
|
+
* @param {{ isDirectory(): boolean, uid: number, mode: number }} st
|
|
467
|
+
* @param {{ uid?: number | null, rootMayOwn?: boolean }} [opts] `uid` as in
|
|
468
|
+
* {@link ownedByCurrentUser}
|
|
469
|
+
* @returns {boolean}
|
|
470
|
+
*/
|
|
471
|
+
export function isPrivateDir(st: {
|
|
472
|
+
isDirectory(): boolean;
|
|
473
|
+
uid: number;
|
|
474
|
+
mode: number;
|
|
475
|
+
}, { uid, rootMayOwn }?: {
|
|
476
|
+
uid?: number | null;
|
|
477
|
+
rootMayOwn?: boolean;
|
|
478
|
+
}): boolean;
|
|
426
479
|
/**
|
|
427
480
|
* Create a presence sentinel at `path` without following a symlink a co-tenant
|
|
428
481
|
* may have pre-planted there. These sentinels live at predictable, world-visible
|
|
@@ -486,19 +539,7 @@ export const PermissionDecision: Readonly<{
|
|
|
486
539
|
* by construction, not by remembering to set an env var.
|
|
487
540
|
*/
|
|
488
541
|
export const FAIL_OPEN_ENV: "AGENT_SANITIZER_FAIL_OPEN";
|
|
489
|
-
|
|
490
|
-
* Values that turn the default posture back to fail-closed. Matched exactly:
|
|
491
|
-
* a case-insensitive match would need `tr`, which the launcher cannot reach
|
|
492
|
-
* (it runs its no-node arm on shell builtins alone).
|
|
493
|
-
*
|
|
494
|
-
* THE SINGLE SOURCE OF TRUTH for the closed set. The shell shims cannot import
|
|
495
|
-
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
|
|
496
|
-
* `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
|
|
497
|
-
* plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
|
|
498
|
-
* out by hand is an implementation that must appear in the parity table in
|
|
499
|
-
* tests/test_safe_launch.py.
|
|
500
|
-
*/
|
|
501
|
-
export const FAIL_CLOSED_VALUES: readonly string[];
|
|
542
|
+
export { FAIL_CLOSED_VALUES } from "./knobs.mjs";
|
|
502
543
|
/**
|
|
503
544
|
* The public knob that turns individual hooks off: a comma-separated list of
|
|
504
545
|
* hook names (the `--hook=` modes). The layer opt-outs
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `raw` as a finite positive number of milliseconds, else `fallback`. A bare
|
|
3
|
+
* `Number(env) || fallback` silently accepts a NEGATIVE override (`-5 || 8000`
|
|
4
|
+
* is -5), and a non-positive deadline makes a fail-closed wait return at once.
|
|
5
|
+
* @param {string | undefined} raw
|
|
6
|
+
* @param {number} fallback
|
|
7
|
+
* @returns {number}
|
|
8
|
+
*/
|
|
9
|
+
export function positiveMsOr(raw: string | undefined, fallback: number): number;
|
|
10
|
+
/**
|
|
11
|
+
* The parsed value of knob `name` in `env`. An unrecognized value is reported
|
|
12
|
+
* through `report` and reads as the knob's fallback; a name missing from
|
|
13
|
+
* {@link KNOBS} throws, since it is a wiring bug in this package rather than
|
|
14
|
+
* operator input.
|
|
15
|
+
* @template {keyof Knobs} K
|
|
16
|
+
* @param {K} name
|
|
17
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
18
|
+
* @param {(message: string) => void} [report]
|
|
19
|
+
* @returns {Knobs[K]["fallback"]}
|
|
20
|
+
*/
|
|
21
|
+
export function readKnob<K extends keyof Knobs>(name: K, env?: NodeJS.ProcessEnv | Record<string, string | undefined>, report?: (message: string) => void): Knobs[K]["fallback"];
|
|
22
|
+
/**
|
|
23
|
+
* THE environment knobs the hooks and the CLI read, as one table.
|
|
24
|
+
*
|
|
25
|
+
* Every `AGENT_SANITIZER_*` / `_AGENT_SANITIZER_*` read goes through
|
|
26
|
+
* {@link readKnob}, which looks the name up here, parses the raw value with the
|
|
27
|
+
* entry's own grammar, and REPORTS a value the grammar does not recognize
|
|
28
|
+
* instead of silently treating it as unset. Seven hand-rolled parsers each
|
|
29
|
+
* decided that alone, and each failed the same way: `SECRETS_ENABLED=true`
|
|
30
|
+
* left the secret layer off, `FAIL_OPEN=FALSE` left the hooks open, and
|
|
31
|
+
* `MAX_INPUT_BYTES=0.5` became a zero-byte cap — all without a word.
|
|
32
|
+
*
|
|
33
|
+
* A bad value is reported, never thrown: these variables are set outside the
|
|
34
|
+
* session, so a throw (or a blocking verdict) would fail every tool call on a
|
|
35
|
+
* config value the session cannot edit. The entry's `fallback` is what the
|
|
36
|
+
* value then reads as, and `consequence` says so in the report.
|
|
37
|
+
*
|
|
38
|
+
* `test/claude-hooks-knobs.test.mjs` parses every hook module and the CLI and
|
|
39
|
+
* asserts the knobs they read are exactly this table, and that nothing reads
|
|
40
|
+
* one past {@link readKnob}.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Values of AGENT_SANITIZER_FAIL_OPEN that select the fail-closed posture.
|
|
44
|
+
* Matched exactly: a case-insensitive match would need `tr`, which the launcher
|
|
45
|
+
* cannot reach (it runs its no-node arm on shell builtins alone).
|
|
46
|
+
*
|
|
47
|
+
* THE single source of truth for the closed set. The shell shims cannot import
|
|
48
|
+
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
|
|
49
|
+
* `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
|
|
50
|
+
* plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
|
|
51
|
+
* out by hand is an implementation that must appear in the parity table in
|
|
52
|
+
* tests/test_safe_launch.py.
|
|
53
|
+
*/
|
|
54
|
+
export const FAIL_CLOSED_VALUES: readonly string[];
|
|
55
|
+
/** Every knob, by variable name. */
|
|
56
|
+
export const KNOBS: Readonly<{
|
|
57
|
+
AGENT_SANITIZER_FAIL_OPEN: Readonly<Knob<boolean> & {
|
|
58
|
+
surface: Surface;
|
|
59
|
+
}>;
|
|
60
|
+
AGENT_SANITIZER_DISABLED_HOOKS: Readonly<Knob<string | undefined> & {
|
|
61
|
+
surface: Surface;
|
|
62
|
+
}>;
|
|
63
|
+
AGENT_SANITIZER_SECRETS_ENABLED: Readonly<Knob<boolean> & {
|
|
64
|
+
surface: Surface;
|
|
65
|
+
}>;
|
|
66
|
+
AGENT_SANITIZER_FLAG_DIGEST_VALUES: Readonly<Knob<boolean> & {
|
|
67
|
+
surface: Surface;
|
|
68
|
+
}>;
|
|
69
|
+
AGENT_SANITIZER_INVISIBLE_DISABLED: Readonly<Knob<boolean> & {
|
|
70
|
+
surface: Surface;
|
|
71
|
+
}>;
|
|
72
|
+
AGENT_SANITIZER_TERMINAL_DISABLED: Readonly<Knob<boolean> & {
|
|
73
|
+
surface: Surface;
|
|
74
|
+
}>;
|
|
75
|
+
AGENT_SANITIZER_OUTPUT_DISABLED: Readonly<Knob<boolean> & {
|
|
76
|
+
surface: Surface;
|
|
77
|
+
}>;
|
|
78
|
+
AGENT_SANITIZER_MAX_INPUT_BYTES: Readonly<Knob<number> & {
|
|
79
|
+
surface: Surface;
|
|
80
|
+
}>;
|
|
81
|
+
_AGENT_SANITIZER_EXTRA_SECRET_VARS: Readonly<Knob<string | undefined> & {
|
|
82
|
+
surface: Surface;
|
|
83
|
+
}>;
|
|
84
|
+
_AGENT_SANITIZER_TRACE: Readonly<Knob<number> & {
|
|
85
|
+
surface: Surface;
|
|
86
|
+
}>;
|
|
87
|
+
_AGENT_SANITIZER_TRACE_FILE: Readonly<Knob<string | undefined> & {
|
|
88
|
+
surface: Surface;
|
|
89
|
+
}>;
|
|
90
|
+
_AGENT_SANITIZER_REDACTOR_SOCKET: Readonly<Knob<string | undefined> & {
|
|
91
|
+
surface: Surface;
|
|
92
|
+
}>;
|
|
93
|
+
_AGENT_SANITIZER_REDACTOR_DAEMON: Readonly<Knob<string | undefined> & {
|
|
94
|
+
surface: Surface;
|
|
95
|
+
}>;
|
|
96
|
+
_AGENT_SANITIZER_REDACTOR_WAIT_MS: Readonly<Knob<number> & {
|
|
97
|
+
surface: Surface;
|
|
98
|
+
}>;
|
|
99
|
+
_AGENT_SANITIZER_REDACTOR_REQUEST_MS: Readonly<Knob<number> & {
|
|
100
|
+
surface: Surface;
|
|
101
|
+
}>;
|
|
102
|
+
_AGENT_SANITIZER_SANITIZE_BUDGET_MS: Readonly<Knob<number> & {
|
|
103
|
+
surface: Surface;
|
|
104
|
+
}>;
|
|
105
|
+
_AGENT_SANITIZER_REVEAL_DIR: Readonly<Knob<string | undefined> & {
|
|
106
|
+
surface: Surface;
|
|
107
|
+
}>;
|
|
108
|
+
}>;
|
|
109
|
+
/**
|
|
110
|
+
* The operator knobs the plugin's shell launcher (plugin/scripts/) reads before
|
|
111
|
+
* any JS runs. They share plugin/README.md's knob tables with the `hooks`
|
|
112
|
+
* surface, so the tables document exactly these plus that surface; JS never
|
|
113
|
+
* reads them (the partition test holds JS reads to KNOBS).
|
|
114
|
+
*/
|
|
115
|
+
export const LAUNCHER_KNOBS: readonly string[];
|
|
116
|
+
/**
|
|
117
|
+
* One knob: its grammar, the value an unrecognized input reads as, and the two
|
|
118
|
+
* halves of the report naming it.
|
|
119
|
+
*/
|
|
120
|
+
export type Knob<T> = {
|
|
121
|
+
parse: (raw: string | undefined) => T | typeof INVALID;
|
|
122
|
+
fallback: T;
|
|
123
|
+
expect: string;
|
|
124
|
+
consequence: string;
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Who sets a knob, which is where it must be documented (asserted in
|
|
128
|
+
* test/claude-hooks-knobs.test.mjs):
|
|
129
|
+
* - `hooks`: a Claude Code plugin operator — plugin/README.md's knob tables;
|
|
130
|
+
* - `cli`: a caller of bin/sanitize-cli.mjs — README.md;
|
|
131
|
+
* - `internal`: `_`-prefixed plumbing hooks.json and tests set — no stable
|
|
132
|
+
* interface, so documented nowhere by name.
|
|
133
|
+
*/
|
|
134
|
+
export type Surface = "hooks" | "cli" | "internal";
|
|
135
|
+
export type Knobs = typeof KNOBS;
|
|
136
|
+
/** What a parser returns for a value its grammar does not recognize. */
|
|
137
|
+
declare const INVALID: unique symbol;
|
|
138
|
+
export {};
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parse a millisecond deadline from an env override, falling back to `fallback`
|
|
3
|
-
* unless the value is a finite positive number. A bare `Number(env) || fallback`
|
|
4
|
-
* silently accepts a NEGATIVE override (`-5 || 8000` is -5) — a non-positive
|
|
5
|
-
* deadline makes the fail-closed wait/request return immediately, defeating the
|
|
6
|
-
* deadline. Unset/blank/NaN/<=0 all take the sane positive fallback; a load-time
|
|
7
|
-
* throw is deliberately avoided so a misconfigured env can never crash these
|
|
8
|
-
* fail-closed hooks into a fail-OPEN non-load.
|
|
9
|
-
* @param {string|undefined} raw the env override value
|
|
10
|
-
* @param {number} fallback the sane positive default
|
|
11
|
-
* @returns {number}
|
|
12
|
-
*/
|
|
13
|
-
export function positiveMsOr(raw: string | undefined, fallback: number): number;
|
|
14
1
|
/**
|
|
15
2
|
* The shape the redactor returns: plain mode `{text, found}`, map mode
|
|
16
3
|
* `{text, pairs, found}` or `{unmappable}`. All fields optional so a consumer
|
|
@@ -40,12 +27,12 @@ export function positiveMsOr(raw: string | undefined, fallback: number): number;
|
|
|
40
27
|
* `lstat`/`uid` are injectable seams so a test can drive a stat shape the test
|
|
41
28
|
* process cannot create (a dir owned by another uid); production binds the real ones.
|
|
42
29
|
* @param {string} socketPath
|
|
43
|
-
* @param {{lstat?: typeof lstatSync, uid?: number}} [deps]
|
|
30
|
+
* @param {{lstat?: typeof lstatSync, uid?: number | null}} [deps]
|
|
44
31
|
* @returns {"absent" | "untrusted" | "ok"}
|
|
45
32
|
*/
|
|
46
33
|
export function classifySocket(socketPath: string, deps?: {
|
|
47
34
|
lstat?: typeof lstatSync;
|
|
48
|
-
uid?: number;
|
|
35
|
+
uid?: number | null;
|
|
49
36
|
}): "absent" | "untrusted" | "ok";
|
|
50
37
|
/**
|
|
51
38
|
* Open one connection, send `request`, resolve with the parsed response object
|
|
@@ -116,6 +103,7 @@ export function redactViaDaemon(text: string, opts?: {
|
|
|
116
103
|
now?: () => number;
|
|
117
104
|
}): Promise<RedactResponse | null>;
|
|
118
105
|
export const FRAME_CAP: number;
|
|
106
|
+
export { positiveMsOr } from "./knobs.mjs";
|
|
119
107
|
export const DEFAULT_SOCKET_PATH: string;
|
|
120
108
|
/**
|
|
121
109
|
* The shape the redactor returns: plain mode `{text, found}`, map mode
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug.
|
|
3
|
-
*
|
|
4
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
2
|
+
* Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug. Empty
|
|
3
|
+
* or "off" → 0; an unrecognized value is reported and reads as 0.
|
|
4
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
5
5
|
* @returns {number}
|
|
6
6
|
*/
|
|
7
|
-
export function traceThreshold(env?: NodeJS.ProcessEnv): number;
|
|
7
|
+
export function traceThreshold(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): number;
|
|
8
|
+
/**
|
|
9
|
+
* THE trace line's record: the envelope ({@link TRACE_RESERVED_KEYS}) first,
|
|
10
|
+
* then `fields`. A field named like an envelope key cannot overwrite it — a
|
|
11
|
+
* reader buckets on `level` and `event`, so a producer field `level: "debug"`
|
|
12
|
+
* would misfile the line — and is not dropped silently either: its name lands in
|
|
13
|
+
* `shadowed`, so the producer bug is visible on the channel it broke.
|
|
14
|
+
* @param {string} event
|
|
15
|
+
* @param {Record<string, unknown>} fields
|
|
16
|
+
* @param {"info"|"debug"} level
|
|
17
|
+
* @param {number} ts
|
|
18
|
+
* @returns {Record<string, unknown>}
|
|
19
|
+
*/
|
|
20
|
+
export function traceRecord(event: string, fields: Record<string, unknown>, level: "info" | "debug", ts: number): Record<string, unknown>;
|
|
8
21
|
/**
|
|
9
22
|
* Emit one JSON trace line for `event` at `level` (default "info") carrying the
|
|
10
23
|
* metadata `fields`. No-op when the channel is below `level`; best-effort on write.
|
|
@@ -70,6 +83,8 @@ export const TraceEvent: Readonly<{
|
|
|
70
83
|
SCAN_INVISIBLE_CHARS_RAN: "scan_invisible_chars_ran";
|
|
71
84
|
SCAN_LOADED_INSTRUCTIONS_RAN: "scan_loaded_instructions_ran";
|
|
72
85
|
}>;
|
|
86
|
+
/** The keys every trace line's envelope owns; a field cannot set them. */
|
|
87
|
+
export const TRACE_RESERVED_KEYS: readonly string[];
|
|
73
88
|
/**
|
|
74
89
|
* The sink shape a hook emits through: the event name, its metadata fields, and
|
|
75
90
|
* the level. A host implementation receives the same {@link TraceEvent} names the
|
|
@@ -191,15 +191,14 @@ export function emitFailClosed(input: any, message: string, emit?: (fields: Reco
|
|
|
191
191
|
* fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
|
|
192
192
|
* the original tool output in the model's view — or the fail-closed
|
|
193
193
|
* suppression of {@link emitFailClosed} when the caller set
|
|
194
|
-
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
194
|
+
* AGENT_SANITIZER_FAIL_OPEN=0, or opted into Layer 4 (see the policy below).
|
|
195
195
|
*
|
|
196
196
|
* This is the hook where the two postures diverge the most, so state the open
|
|
197
197
|
* one plainly: several of these layers throw on inputs an attacker composes (a
|
|
198
|
-
* nesting depth that overflows the walk,
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* keeping the session moving sets the knob to `0`.
|
|
198
|
+
* nesting depth that overflows the walk), and each of those throws is guarding
|
|
199
|
+
* content the open posture hands to the model verbatim. An operator who cares
|
|
200
|
+
* more about withholding that than about keeping the session moving sets the
|
|
201
|
+
* knob to `0`.
|
|
203
202
|
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
204
203
|
* @param {unknown} err
|
|
205
204
|
* @param {(fields: Record<string, unknown>) => void} [emit]
|