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.
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { appendFileSync } from "node:fs";
22
22
  import { chargeHostExtensionSync } from "./hook-timing.mjs";
23
+ import { readKnob } from "./knobs.mjs";
23
24
 
24
25
  /**
25
26
  * The sink shape a hook emits through: the event name, its metadata fields, and
@@ -42,16 +43,44 @@ export const TraceEvent = Object.freeze({
42
43
  const LEVELS = Object.freeze({ off: 0, info: 1, debug: 2 });
43
44
 
44
45
  /**
45
- * Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug.
46
- * Unknown, empty, or "off" → 0.
47
- * @param {NodeJS.ProcessEnv} [env]
46
+ * Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug. Empty
47
+ * or "off" → 0; an unrecognized value is reported and reads as 0.
48
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
48
49
  * @returns {number}
49
50
  */
50
51
  export function traceThreshold(env = process.env) {
51
- const value = (env._AGENT_SANITIZER_TRACE ?? "").toLowerCase();
52
- if (value === "debug" || value === "2") return LEVELS.debug;
53
- if (["info", "1", "true", "on"].includes(value)) return LEVELS.info;
54
- return LEVELS.off;
52
+ return readKnob("_AGENT_SANITIZER_TRACE", env);
53
+ }
54
+
55
+ /** The keys every trace line's envelope owns; a field cannot set them. */
56
+ export const TRACE_RESERVED_KEYS = Object.freeze(["ts", "level", "event"]);
57
+
58
+ /**
59
+ * THE trace line's record: the envelope ({@link TRACE_RESERVED_KEYS}) first,
60
+ * then `fields`. A field named like an envelope key cannot overwrite it — a
61
+ * reader buckets on `level` and `event`, so a producer field `level: "debug"`
62
+ * would misfile the line — and is not dropped silently either: its name lands in
63
+ * `shadowed`, so the producer bug is visible on the channel it broke.
64
+ * @param {string} event
65
+ * @param {Record<string, unknown>} fields
66
+ * @param {"info"|"debug"} level
67
+ * @param {number} ts
68
+ * @returns {Record<string, unknown>}
69
+ */
70
+ export function traceRecord(event, fields, level, ts) {
71
+ const shadowed = TRACE_RESERVED_KEYS.filter((key) =>
72
+ Object.hasOwn(fields, key),
73
+ );
74
+ const rest = Object.fromEntries(
75
+ Object.entries(fields).filter(([key]) => !shadowed.includes(key)),
76
+ );
77
+ return {
78
+ ts,
79
+ level,
80
+ event,
81
+ ...rest,
82
+ ...(shadowed.length > 0 ? { shadowed } : {}),
83
+ };
55
84
  }
56
85
 
57
86
  /**
@@ -69,8 +98,8 @@ export function trace(event, fields = {}, level = "info") {
69
98
  const lvl = level === "debug" ? "debug" : "info";
70
99
  if (traceThreshold() < LEVELS[lvl]) return;
71
100
  const line =
72
- JSON.stringify({ ts: Date.now(), level: lvl, event, ...fields }) + "\n";
73
- const file = process.env._AGENT_SANITIZER_TRACE_FILE;
101
+ JSON.stringify(traceRecord(event, fields, lvl, Date.now())) + "\n";
102
+ const file = readKnob("_AGENT_SANITIZER_TRACE_FILE");
74
103
  try {
75
104
  if (file) appendFileSync(file, line);
76
105
  else process.stderr.write(line);
@@ -26,6 +26,7 @@ import {
26
26
  } from "./lib/hook-io.mjs";
27
27
  import {
28
28
  registerFaultPolicy,
29
+ KNOB_DECIDES,
29
30
  hookFaultOutcome,
30
31
  writeFaultOutcome,
31
32
  } from "./lib/hook-fault.mjs";
@@ -54,6 +55,7 @@ const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
54
55
  registerFaultPolicy(HOOK_NAME, {
55
56
  event: null,
56
57
  guarded: "hook payload",
58
+ alwaysClosed: KNOB_DECIDES,
57
59
  open: unknownMode,
58
60
  closed: unknownMode,
59
61
  });
@@ -45,6 +45,7 @@ import {
45
45
  } from "./lib/hook-io.mjs";
46
46
  import {
47
47
  registerFaultPolicy,
48
+ KNOB_DECIDES,
48
49
  hookFaultOutcome,
49
50
  defaultOpen,
50
51
  } from "./lib/hook-fault.mjs";
@@ -65,6 +66,7 @@ import {
65
66
  } from "./lib/authored-content.mjs";
66
67
  import { redactViaDaemon } from "./lib/redactor-client.mjs";
67
68
  import { secretsEnabled } from "./lib/env-config.mjs";
69
+ import { readKnob } from "./lib/knobs.mjs";
68
70
  import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
69
71
  import {
70
72
  placeholderNotice,
@@ -159,9 +161,11 @@ const confusableScan = (text) =>
159
161
  const redactorIo = {
160
162
  readFile: (path) => readFileSync(path, "utf8"),
161
163
  redactMap: async (text) =>
162
- /** @type {any} */ (await redactViaDaemon(text, { map: true })),
164
+ /** @type {any} */ (
165
+ await redactViaDaemon(text, { map: true, webIngress: false })
166
+ ),
163
167
  redact: async (text) => {
164
- const out = await redactViaDaemon(text, {});
168
+ const out = await redactViaDaemon(text, { webIngress: false });
165
169
  return out ? /** @type {string} */ (out.text) : null;
166
170
  },
167
171
  };
@@ -420,7 +424,7 @@ export function preToolUseLayers(rehydrate, env = process.env) {
420
424
  run: (tool, toolInput) => rehydrateLayer2(tool, toolInput),
421
425
  },
422
426
  ];
423
- return env.AGENT_SANITIZER_OUTPUT_DISABLED === "1"
427
+ return readKnob("AGENT_SANITIZER_OUTPUT_DISABLED", env)
424
428
  ? layers.filter((layer) => layer.name !== "authored-content")
425
429
  : layers;
426
430
  }
@@ -848,6 +852,7 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
848
852
  registerFaultPolicy(HOOK_NAME, {
849
853
  event: HookEvent.PRE_TOOL_USE,
850
854
  guarded: "tool input",
855
+ alwaysClosed: KNOB_DECIDES,
851
856
  open: (ctx) => {
852
857
  // Non-carve-out faults take the SHARED open rendering, not a copy of it —
853
858
  // hook-fault.mjs owns that body, and a restated one would silently drift.
@@ -24,7 +24,8 @@
24
24
  * Layer 3 and the strict secret mode run on all MCP connector output (see
25
25
  * isUntrustedIngress).
26
26
  */
27
- import { redactViaDaemon, positiveMsOr } from "./lib/redactor-client.mjs";
27
+ import { redactViaDaemon } from "./lib/redactor-client.mjs";
28
+ import { readKnob } from "./lib/knobs.mjs";
28
29
  import {
29
30
  chargeHostExtension,
30
31
  chargeHostExtensionSync,
@@ -49,7 +50,11 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
49
50
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
50
51
  import { hookTrace, TraceEvent } from "./lib/trace.mjs";
51
52
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
52
- import { digestFlaggingEnabled, secretsEnabled } from "./lib/env-config.mjs";
53
+ import {
54
+ digestFlaggingEnabled,
55
+ secretsEnabled,
56
+ SECRETS_ENABLED_ENV,
57
+ } from "./lib/env-config.mjs";
53
58
  import {
54
59
  persistReveal,
55
60
  persistSpan,
@@ -118,10 +123,7 @@ export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
118
123
  // would be shown (fail OPEN). The default sits comfortably above one legitimately
119
124
  // slow leaf (a cold redactor respawn + a full scan) yet far under the hook
120
125
  // timeout; env-tunable so tests can drive the exhausted-budget path fast.
121
- const SANITIZE_BUDGET_MS = positiveMsOr(
122
- process.env._AGENT_SANITIZER_SANITIZE_BUDGET_MS,
123
- 120000,
124
- );
126
+ const SANITIZE_BUDGET_MS = readKnob("_AGENT_SANITIZER_SANITIZE_BUDGET_MS");
125
127
 
126
128
  // Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
127
129
  // Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
@@ -169,11 +171,11 @@ function isUntrustedIngress(toolName) {
169
171
  * daemon. A transient daemon failure fails only THIS call — no session-wide
170
172
  * sentinel — and the client respawns a dead daemon on the next call.
171
173
  * @param {string} text
172
- * @param {boolean} [webIngress]
174
+ * @param {boolean} webIngress
173
175
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget
174
176
  * @returns {Promise<{ text: string, found: string[] } | null>}
175
177
  */
176
- async function redactSecrets(text, webIngress = false, deadline) {
178
+ async function redactSecrets(text, webIngress, deadline) {
177
179
  if (!matchesSecretHint(text) && !hasEnvBoundSecret(text)) return null;
178
180
  // On web ingress the field name around a value is attacker-controlled, so the
179
181
  // redactor's benign-skip heuristics (metadata field / cursor / path) are a
@@ -276,11 +278,11 @@ export async function sanitizeText(
276
278
  // Layer 4 — OPT-IN (secretsEnabled): with the knob unset the seam gets no
277
279
  // redact callback at all, so plain output never spawns the daemon and no
278
280
  // placeholder ever enters the model's view. When it runs, the seam rethrows
279
- // a redactor throw wrapped, and the CLI applies the caller's posture to it.
280
- // Surface the failure to the operator's terminal here first: whatever the
281
- // CLI decides rides in additionalContext, which only the model sees, so a
282
- // degraded redactor would otherwise be invisible to the human — and under
283
- // the fail-open default this line is the ONLY signal the human gets.
281
+ // a redactor throw wrapped, and this hook's fault policy suppresses the
282
+ // output under either posture (alwaysClosed). Surface the failure to the
283
+ // operator's terminal here first: the suppression note rides in what only
284
+ // the model sees, so a degraded redactor would otherwise be invisible to
285
+ // the human.
284
286
  redact: !secretsEnabled()
285
287
  ? undefined
286
288
  : async (/** @type {string} */ content) => {
@@ -783,15 +785,14 @@ function failClosedParts(
783
785
  * fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
784
786
  * the original tool output in the model's view — or the fail-closed
785
787
  * suppression of {@link emitFailClosed} when the caller set
786
- * AGENT_SANITIZER_FAIL_OPEN=0.
788
+ * AGENT_SANITIZER_FAIL_OPEN=0, or opted into Layer 4 (see the policy below).
787
789
  *
788
790
  * This is the hook where the two postures diverge the most, so state the open
789
791
  * one plainly: several of these layers throw on inputs an attacker composes (a
790
- * nesting depth that overflows the walk, a redaction budget spent on a thousand
791
- * secret-shaped leaves), and each of those throws is
792
- * guarding content the open posture hands to the model verbatim, secrets
793
- * included. An operator who cares more about withholding a secret than about
794
- * keeping the session moving sets the knob to `0`.
792
+ * nesting depth that overflows the walk), and each of those throws is guarding
793
+ * content the open posture hands to the model verbatim. An operator who cares
794
+ * more about withholding that than about keeping the session moving sets the
795
+ * knob to `0`.
795
796
  * @param {any} input parsed hook input, or undefined if parsing threw
796
797
  * @param {unknown} err
797
798
  * @param {(fields: Record<string, unknown>) => void} [emit]
@@ -830,14 +831,29 @@ function suppressionMessage(cause) {
830
831
  return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
831
832
  }
832
833
 
834
+ /** Why an open knob still suppressed: the secret layer's own guarantee. */
835
+ const SECRETS_PIN_NOTE =
836
+ ` Secret redaction is on (${SECRETS_ENABLED_ENV}=1), so output it could ` +
837
+ "not vet is withheld under either AGENT_SANITIZER_FAIL_OPEN posture.";
838
+
833
839
  // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
834
840
  // shared default (a warning context, the original output left in the model's
835
841
  // view); CLOSED replaces every string leaf of the output with the placeholder.
842
+ // With Layer 4 opted in, a fault here means the output was never vetted for
843
+ // secrets — a dead daemon, a spent budget, a walk that overflowed before the
844
+ // redactor ran — so this pin is what keeps the open default from handing an
845
+ // unredacted credential to the model.
836
846
  registerFaultPolicy(HOOK_NAME, {
837
847
  event: HookEvent.POST_TOOL_USE,
838
848
  guarded: "tool output",
849
+ alwaysClosed: secretsEnabled,
839
850
  closed: (ctx) =>
840
- failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
851
+ failClosedParts(
852
+ ctx.input,
853
+ suppressionMessage(ctx.message) +
854
+ (ctx.pinnedClosed ? SECRETS_PIN_NOTE : ""),
855
+ ctx.remedy,
856
+ ),
841
857
  });
842
858
 
843
859
  /**
@@ -1137,9 +1153,9 @@ export async function cliMain(ext = {}) {
1137
1153
  // back the parsed `input` even when the control-plane load failed, so the
1138
1154
  // suppression shape-matches the real tool_response). emitFailClosed itself
1139
1155
  // falls back to a bare string if that shape-matching replacement or its
1140
- // serialization throws, so even a pathological input fails closed. A caller
1141
- // that set AGENT_SANITIZER_FAIL_OPEN=1 gets the warning-only pass-through
1142
- // instead — see emitHookFailure.
1156
+ // serialization throws, so even a pathological input fails closed. Under
1157
+ // the open default with Layer 4 off the caller gets the warning-only
1158
+ // pass-through instead — see emitHookFailure.
1143
1159
  onError: (err, input) =>
1144
1160
  emitHookFailure(input, err, undefined, ext.remedy),
1145
1161
  },
@@ -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.59.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -209,6 +209,7 @@
209
209
  "python/agent_sanitizer/data/invisible-charset.json",
210
210
  "python/agent_sanitizer/secrets/data/credential-names.json",
211
211
  "python/agent_sanitizer/secrets/data/redaction-floor.json",
212
+ "python/agent_sanitizer/secrets/data/redactor-protocol.json",
212
213
  "claude-hooks/*.mjs",
213
214
  "claude-hooks/lib/*.mjs",
214
215
  "claude-hooks/config/*.json",
package/python/README.md CHANGED
@@ -97,9 +97,12 @@ console scripts (installed with the extra) cover both modes:
97
97
  server that configures the plugin set **once** at startup (a warm-up scan
98
98
  primes the cache before it binds, so a bound socket means ready). Wire protocol
99
99
  both directions: a 4-byte big-endian length prefix then that many bytes of
100
- UTF-8 JSON. Request `{"text", "map", "web_ingress", "env_secrets"}`; response is
101
- the one-shot result, JSON `null` when nothing is redacted, or `{"error": …}` on
102
- a scan failure.
100
+ UTF-8 JSON, specified field by field in
101
+ `agent_sanitizer/secrets/data/redactor-protocol.json`. Every request field
102
+ (`text`, `map`, `web_ingress`, `env_secrets`) is required; the response is the
103
+ one-shot result, JSON `null` when nothing is redacted, or `{"error": …}` for a
104
+ malformed request or a scan failure. The daemon logs failures to `daemon.log`
105
+ beside the socket.
103
106
 
104
107
  Both console scripts import the engine on startup, so they fail loud if the
105
108
  `[secrets]` extra (hence `detect-secrets`) is not installed — fail closed, never
@@ -0,0 +1,17 @@
1
+ {
2
+ "description": "Wire protocol of the Layer-4 secret-redactor daemon. Both directions: a 4-byte big-endian unsigned length, then that many bytes of UTF-8 JSON, at most frame_cap bytes. The client (claude-hooks/lib/redactor-client.mjs) and the daemon (python/agent_sanitizer/secrets/daemon.py) both read this file. Every object has exactly the listed fields. Field types: string, boolean, index (a non-negative integer), string-list, string-map (an object whose values are strings), pair-list (a list of `pair` objects). A plain-mode reply may also be JSON null, meaning there was nothing to redact.",
3
+ "frame_cap": 16777216,
4
+ "request": {
5
+ "text": "string",
6
+ "map": "boolean",
7
+ "web_ingress": "boolean",
8
+ "env_secrets": "string-map"
9
+ },
10
+ "responses": {
11
+ "plain": { "text": "string", "found": "string-list" },
12
+ "map": { "text": "string", "pairs": "pair-list", "found": "string-list" },
13
+ "unmappable": { "unmappable": "string" },
14
+ "error": { "error": "string" }
15
+ },
16
+ "pair": { "placeholder": "string", "original": "string", "start": "index" }
17
+ }
@@ -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