agent-sanitizer 2.58.10 → 2.59.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.
@@ -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,49 +27,62 @@ 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
52
39
  * (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
53
40
  * {error} response — every one of which the caller turns into a fail-closed. A
54
- * socket present but not owned by us fails closed WITHOUT respawning (the error
55
- * carries no errno, so isRespawnable is false), so we never dial into a squat.
41
+ * socket present but not owned by us, and a request the protocol does not
42
+ * accept, fail closed WITHOUT dialing (the error carries no errno, so
43
+ * isRespawnable is false): we never dial into a squat, and an oversize request
44
+ * is not mistaken for a dead daemon worth respawning.
56
45
  * @param {string} socketPath
57
- * @param {{text: string, map: boolean, web_ingress: boolean}} request
46
+ * @param {Record<string, unknown>} request
58
47
  * @param {number} [deadlineMs] total exchange deadline; defaults to the env-tunable value
59
48
  * @returns {Promise<RedactResponse|null>}
60
49
  */
61
- export function connectAndRequest(socketPath: string, request: {
62
- text: string;
63
- map: boolean;
64
- web_ingress: boolean;
65
- }, deadlineMs?: number): Promise<RedactResponse | null>;
50
+ export function connectAndRequest(socketPath: string, request: Record<string, unknown>, deadlineMs?: number): Promise<RedactResponse | null>;
51
+ /**
52
+ * A spawned daemon, as far as the spawning hook can see it.
53
+ * @typedef {object} DaemonHandle
54
+ * @property {() => string | undefined} failure why the daemon died before it was
55
+ * ready (its exit status and stderr tail), or undefined while it may still bind
56
+ * @property {() => void} release stop reading its stderr
57
+ */
66
58
  /**
67
59
  * Spawn the daemon detached so it outlives this hook process. The daemon's bind()
68
- * is the cross-process mutex, so a racing second spawn just exits — the spawn is
69
- * idempotent and needs no lock here.
60
+ * is the cross-process mutex, so a racing second spawn just exits 0 — the spawn
61
+ * is idempotent and needs no lock here.
62
+ *
63
+ * Its stderr is piped back so a daemon that dies on startup (an import error, a
64
+ * refused cache directory) fails the call at once with its own reason rather than
65
+ * after the whole spawn wait with none. The daemon points its stderr at a log
66
+ * file before it publishes the socket, so the pipe is only read until then.
70
67
  * @param {string} socketPath
71
68
  * @param {string[]} [command] daemon command as [argv0, ...leadingArgs]
72
69
  * (injectable so tests can drive the missing-binary arm in-process;
73
70
  * production always uses daemonCommand())
71
+ * @returns {DaemonHandle}
74
72
  */
75
- export function spawnDaemon(socketPath: string, command?: string[]): void;
73
+ export function spawnDaemon(socketPath: string, command?: string[]): DaemonHandle;
76
74
  /**
77
- * Poll until the daemon is accepting connections or the deadline passes. Probes by
78
- * connecting (not just existsSync) so it waits for listen(), not merely bind().
75
+ * Poll until the daemon is accepting connections, the deadline passes, or
76
+ * `abandon` reports the spawned daemon dead. Probes by connecting (not just
77
+ * existsSync) so it waits for listen(), not merely bind().
79
78
  * @param {string} socketPath
80
- * @param {{deadlineMs?: number, stepMs?: number}} [opts]
79
+ * @param {{deadlineMs?: number, stepMs?: number, abandon?: () => boolean}} [opts]
81
80
  * @returns {Promise<boolean>}
82
81
  */
83
- export function waitForSocket(socketPath: string, { deadlineMs, stepMs }?: {
82
+ export function waitForSocket(socketPath: string, { deadlineMs, stepMs, abandon }?: {
84
83
  deadlineMs?: number;
85
84
  stepMs?: number;
85
+ abandon?: () => boolean;
86
86
  }): Promise<boolean>;
87
87
  /**
88
88
  * Redact `text` via the daemon. Returns the response object (`{text, found}` for
@@ -96,26 +96,32 @@ export function waitForSocket(socketPath: string, { deadlineMs, stepMs }?: {
96
96
  * budget fails CLOSED without dialing — never dial with a non-positive deadline,
97
97
  * which would race and could return the raw, unvetted secret (fail open). Omitted,
98
98
  * the redactor keeps its own per-call request deadline (the standalone default).
99
+ *
100
+ * `webIngress` is required: it chooses between the name-trusting heuristics for
101
+ * local output and the stricter ones for attacker-controlled text, and a
102
+ * default would make that choice silently for any caller that forgot it.
99
103
  * @param {string} text
100
- * @param {{map?: boolean, webIngress?: boolean, socketPath?: string,
104
+ * @param {{map?: boolean, webIngress: boolean, socketPath?: string,
101
105
  * deadline?: {remainingMs: () => number},
102
- * connect?: typeof connectAndRequest, spawn?: typeof spawnDaemon,
103
- * waitForSocket?: typeof waitForSocket, now?: () => number}} [opts]
106
+ * connect?: typeof connectAndRequest,
107
+ * spawn?: (socketPath: string) => DaemonHandle | void,
108
+ * waitForSocket?: typeof waitForSocket, now?: () => number}} opts
104
109
  * @returns {Promise<RedactResponse|null>}
105
110
  */
106
- export function redactViaDaemon(text: string, opts?: {
111
+ export function redactViaDaemon(text: string, opts: {
107
112
  map?: boolean;
108
- webIngress?: boolean;
113
+ webIngress: boolean;
109
114
  socketPath?: string;
110
115
  deadline?: {
111
116
  remainingMs: () => number;
112
117
  };
113
118
  connect?: typeof connectAndRequest;
114
- spawn?: typeof spawnDaemon;
119
+ spawn?: (socketPath: string) => DaemonHandle | void;
115
120
  waitForSocket?: typeof waitForSocket;
116
121
  now?: () => number;
117
122
  }): Promise<RedactResponse | null>;
118
- export const FRAME_CAP: number;
123
+ export { FRAME_CAP };
124
+ export { positiveMsOr } from "./knobs.mjs";
119
125
  export const DEFAULT_SOCKET_PATH: string;
120
126
  /**
121
127
  * The shape the redactor returns: plain mode `{text, found}`, map mode
@@ -132,4 +138,19 @@ export type RedactResponse = {
132
138
  }[] | undefined;
133
139
  unmappable?: string | undefined;
134
140
  };
141
+ /**
142
+ * A spawned daemon, as far as the spawning hook can see it.
143
+ */
144
+ export type DaemonHandle = {
145
+ /**
146
+ * why the daemon died before it was
147
+ * ready (its exit status and stderr tail), or undefined while it may still bind
148
+ */
149
+ failure: () => string | undefined;
150
+ /**
151
+ * stop reading its stderr
152
+ */
153
+ release: () => void;
154
+ };
135
155
  import { lstatSync } from "node:fs";
156
+ import { FRAME_CAP } from "./redactor-wire.mjs";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @param {unknown} value
3
+ * @returns {value is Record<string, unknown>}
4
+ */
5
+ export function isPlainObject(value: unknown): value is Record<string, unknown>;
6
+ /**
7
+ * Whether `value` is an object with exactly `shape`'s fields, each of its
8
+ * declared protocol type.
9
+ * @param {unknown} value
10
+ * @param {Shape} shape
11
+ * @returns {boolean}
12
+ */
13
+ export function conforms(value: unknown, shape: Shape): boolean;
14
+ /**
15
+ * One frame carrying `value`. Throws (with no errno, so the client does not
16
+ * mistake it for a dead daemon) when the body exceeds the frame cap, which the
17
+ * peer would refuse unread.
18
+ * @param {unknown} value
19
+ * @returns {Buffer}
20
+ */
21
+ export function encodeFrame(value: unknown): Buffer;
22
+ export { PROTOCOL };
23
+ export const FRAME_CAP: number;
24
+ /**
25
+ * Reassembles one frame from the chunks a stream delivers. Holds a running
26
+ * byte total so the backlog is concatenated at most twice (header, then the
27
+ * whole frame) rather than on every chunk, which would be quadratic.
28
+ */
29
+ export class FrameReader {
30
+ /**
31
+ * Feed one chunk. Returns `{ value }` once the whole frame has arrived and
32
+ * undefined before then; throws on a header over the frame cap or a body
33
+ * that is not JSON.
34
+ * @param {Buffer} chunk
35
+ * @returns {{ value: unknown } | undefined}
36
+ */
37
+ push(chunk: Buffer): {
38
+ value: unknown;
39
+ } | undefined;
40
+ #private;
41
+ }
42
+ /**
43
+ * protocol field name -> field type
44
+ */
45
+ export type Shape = Record<string, string>;
46
+ import PROTOCOL from "../../python/agent_sanitizer/secrets/data/redactor-protocol.json" with { type: "json" };
@@ -1,10 +1,23 @@
1
1
  /**
2
- * Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug.
3
- * Unknown, empty, or "off" → 0.
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, a redaction budget spent on a thousand
199
- * secret-shaped leaves), and each of those throws is
200
- * guarding content the open posture hands to the model verbatim, secrets
201
- * included. An operator who cares more about withholding a secret than about
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]