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.
@@ -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.10",
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": {
@@ -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) => join(dirent.parentPath, dirent.name);
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. `=== "1"`
63
- * matches the other public knobs (`AGENT_SANITIZER_*_DISABLED`): any other
64
- * value — unset, "true", "yes" — keeps the layer off, so a typo can only fail
65
- * toward the default (no secret machinery), never silently enable it.
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. `=== "1"`
72
- * for the reason {@link secretsEnabled} gives: a typo fails toward the default,
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 three values every hook derived by hand
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
- * A hook's declared posture.
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
- * 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]