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.
- 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 +161 -126
- package/claude-hooks/lib/redactor-wire.mjs +145 -0
- 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 +8 -3
- package/claude-hooks/sanitize-output.mjs +39 -23
- 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 +2 -1
- package/python/README.md +6 -3
- package/python/agent_sanitizer/secrets/data/redactor-protocol.json +17 -0
- 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 +58 -37
- package/types/claude-hooks/lib/redactor-wire.d.mts +46 -0
- package/types/claude-hooks/lib/trace.d.mts +19 -4
- package/types/claude-hooks/sanitize-output.d.mts +5 -6
|
@@ -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
|
|
55
|
-
*
|
|
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 {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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[]):
|
|
73
|
+
export function spawnDaemon(socketPath: string, command?: string[]): DaemonHandle;
|
|
76
74
|
/**
|
|
77
|
-
* Poll until the daemon is accepting connections
|
|
78
|
-
*
|
|
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
|
|
104
|
+
* @param {{map?: boolean, webIngress: boolean, socketPath?: string,
|
|
101
105
|
* deadline?: {remainingMs: () => number},
|
|
102
|
-
* connect?: typeof connectAndRequest,
|
|
103
|
-
*
|
|
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
|
|
113
|
+
webIngress: boolean;
|
|
109
114
|
socketPath?: string;
|
|
110
115
|
deadline?: {
|
|
111
116
|
remainingMs: () => number;
|
|
112
117
|
};
|
|
113
118
|
connect?: typeof connectAndRequest;
|
|
114
|
-
spawn?:
|
|
119
|
+
spawn?: (socketPath: string) => DaemonHandle | void;
|
|
115
120
|
waitForSocket?: typeof waitForSocket;
|
|
116
121
|
now?: () => number;
|
|
117
122
|
}): Promise<RedactResponse | null>;
|
|
118
|
-
export
|
|
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
|
-
*
|
|
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]
|