agent-sanitizer 2.58.10 → 2.58.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/THREAT-MODEL.md CHANGED
@@ -240,11 +240,14 @@ plugin injects the Python engine from `agent-sanitizer[secrets]`
240
240
 
241
241
  **It is the one fail-closed layer.** A redactor that throws — unreachable
242
242
  daemon, engine error — is rethrown from `sanitizeValue` as `CRITICAL: secret
243
- redaction failed …` (`src/output.mjs`), so the caller suppresses the output
244
- rather than emit a value nothing vetted. That is distinct from an engine that
245
- was never provisioned: there the hooks' posture applies, passing the output
246
- through with a loud warning by default and suppressing it under
247
- `AGENT_SANITIZER_FAIL_OPEN=0`. Either way the gap is announced, never silent.
243
+ redaction failed …` (`src/output.mjs`).
244
+ With the layer opted in, the output hook's fault policy (`alwaysClosed` in
245
+ `claude-hooks/sanitize-output.mjs`, resolved in `lib/hook-fault.mjs`) then
246
+ suppresses the output under **either** `AGENT_SANITIZER_FAIL_OPEN` posture —
247
+ and so does any other fault of that hook, since an output the hook failed on
248
+ was never vetted for secrets either. One accepted gap: a launcher-level failure
249
+ (no `node`, corrupt bundle) never runs the policy, so the launcher's own
250
+ posture applies there, warning by default.
248
251
 
249
252
  **Lone surrogates are normalized to U+FFFD before the redactor sees the text.**
250
253
  A secret split by an interposed lone surrogate renders as contiguous to the
@@ -839,8 +842,10 @@ to hold precision. All other faults keep the open default, and
839
842
  **The open default is not enforceable against content.** Several of those
840
843
  failures are composable by whoever authored the payload — in the output hook
841
844
  alone, a nesting depth that overflows the sanitize walk and a redaction budget
842
- exhausted by many secret-shaped leaves. (A **key collision** — two field names
843
- that collapse to one after Layer 1 — used to be on that list. It no longer
845
+ exhausted by many secret-shaped leaves — though with Layer 4 opted in, the
846
+ output hook withholds on any fault whatever the knob says (see Layer 4). (A
847
+ **key collision** — two field names that collapse to one after Layer 1 — used
848
+ to be on that list. It no longer
844
849
  fails the hook at all: only the colliding fields are withheld, and every sibling
845
850
  field survives. Both colliding values are replaced **whole** by a marker string,
846
851
  not walked leaf-wise: a shape-preserving walk rewrites only string leaves, so a
@@ -51,26 +51,13 @@ import { realpathSync } from "node:fs";
51
51
  import process from "node:process";
52
52
  import { fileURLToPath } from "node:url";
53
53
 
54
+ import { readKnob } from "../claude-hooks/lib/knobs.mjs";
54
55
  import { sanitize } from "../src/index.mjs";
55
56
 
56
57
  /** Largest single request accepted, in UTF-8 bytes. Caps memory per request so
57
- * a hostile or runaway caller can't OOM the process; override via the env var. */
58
- const DEFAULT_MAX_INPUT_BYTES = 10 * 1024 * 1024;
59
-
60
- /** Resolve the configured input cap, falling back to the default for an unset,
61
- * empty, non-numeric, or non-positive value. */
62
- function maxInputBytes() {
63
- const raw = process.env.AGENT_SANITIZER_MAX_INPUT_BYTES;
64
- const parsed = Number(raw);
65
- if (
66
- raw === undefined ||
67
- raw === "" ||
68
- !Number.isFinite(parsed) ||
69
- parsed <= 0
70
- )
71
- return DEFAULT_MAX_INPUT_BYTES;
72
- return Math.floor(parsed);
73
- }
58
+ * a hostile or runaway caller can't OOM the process; override via the env var.
59
+ * An unrecognized value is reported on stderr and the default applies. */
60
+ const maxInputBytes = () => readKnob("AGENT_SANITIZER_MAX_INPUT_BYTES");
74
61
 
75
62
  /** The one-line reason reported for a caught throw. A thrown non-Error that
76
63
  * still carries a `message` keeps reporting it (the historical behaviour);
@@ -50,6 +50,7 @@
50
50
  * AGENT_SANITIZER_OUTPUT_DISABLED=1 disables both.
51
51
  */
52
52
  import { lazyImport } from "./hook-io.mjs";
53
+ import { readKnob } from "./knobs.mjs";
53
54
 
54
55
  // Bound via lazyImport (see its doc for the fail-OPEN hazard of a bare static
55
56
  // npm import — here the load crash would fire inside pretooluse-sanitize.mjs's
@@ -207,7 +208,7 @@ function sanitizeField(value) {
207
208
  // view sanitize-output uses. Compare before/after rather than pre-testing
208
209
  // for ESC: escapes this layer deliberately leaves in place — a lone control
209
210
  // byte, display-only colour — must not be reported as a strip.
210
- if (process.env.AGENT_SANITIZER_TERMINAL_DISABLED !== "1") {
211
+ if (!readKnob("AGENT_SANITIZER_TERMINAL_DISABLED")) {
211
212
  const deAnsi = stripTerminalControls(cleaned);
212
213
  if (deAnsi !== cleaned) {
213
214
  cleaned = deAnsi;
@@ -216,7 +217,7 @@ function sanitizeField(value) {
216
217
  }
217
218
 
218
219
  if (
219
- process.env.AGENT_SANITIZER_INVISIBLE_DISABLED !== "1" &&
220
+ !readKnob("AGENT_SANITIZER_INVISIBLE_DISABLED") &&
220
221
  isPayloadCapable(cleaned)
221
222
  ) {
222
223
  cleaned = stripInvisible(cleaned);
@@ -25,6 +25,7 @@ import credentialNames from "../../python/agent_sanitizer/secrets/data/credentia
25
25
  import redactionFloor from "../../python/agent_sanitizer/secrets/data/redaction-floor.json" with { type: "json" };
26
26
  import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
27
27
  import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
28
+ import { readKnob } from "./knobs.mjs";
28
29
 
29
30
  /**
30
31
  * The inference-provider key env vars. Their values authenticate the agent to a
@@ -205,7 +206,7 @@ const EXTRA_TOKEN_RE = /^[A-Z0-9_]+$/;
205
206
  * @returns {string[]}
206
207
  */
207
208
  export function extraSecretVars(env = process.env) {
208
- const raw = env[EXTRA_SECRET_VARS_ENV];
209
+ const raw = readKnob(EXTRA_SECRET_VARS_ENV, env);
209
210
  if (raw === undefined || raw.trim() === "") return [];
210
211
  const tokens = raw.split(",").map((token) => token.trim());
211
212
  for (const token of tokens)
@@ -227,15 +228,16 @@ export function extraSecretVars(env = process.env) {
227
228
  export const SECRETS_ENABLED_ENV = "AGENT_SANITIZER_SECRETS_ENABLED";
228
229
 
229
230
  /**
230
- * True when the operator opted into the secret-redaction layer. `=== "1"`
231
- * matches the other public knobs (`AGENT_SANITIZER_*_DISABLED`): any other
232
- * value — unset, "true", "yes" — keeps the layer off, so a typo can only fail
233
- * toward the default (no secret machinery), never silently enable it.
231
+ * True when the operator opted into the secret-redaction layer. Only `1`
232
+ * enables it, matching the other public switches (`AGENT_SANITIZER_*_DISABLED`)
233
+ * and the SessionStart provisioning gate in provision-redactor.sh, so the hooks
234
+ * never redact through an engine that was not provisioned. Any other value
235
+ * keeps the layer off — reported, so `true` is not silently a no-op.
234
236
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
235
237
  * @returns {boolean}
236
238
  */
237
239
  export function secretsEnabled(env = process.env) {
238
- return env[SECRETS_ENABLED_ENV] === "1";
240
+ return readKnob(SECRETS_ENABLED_ENV, env);
239
241
  }
240
242
 
241
243
  // Layer 3's digest exemption, which lets a URL carry an exact-digest-length hex
@@ -246,14 +248,14 @@ export function secretsEnabled(env = process.env) {
246
248
  export const FLAG_DIGEST_VALUES_ENV = "AGENT_SANITIZER_FLAG_DIGEST_VALUES";
247
249
 
248
250
  /**
249
- * True when the operator opted into flagging digest-shaped URL values. `=== "1"`
250
- * for the reason {@link secretsEnabled} gives: a typo fails toward the default,
251
- * which is the exemption the precision rule prefers.
251
+ * True when the operator opted into flagging digest-shaped URL values. Only
252
+ * `1`, for the reason {@link secretsEnabled} gives: a typo fails toward the
253
+ * default (reported), which is the exemption the precision rule prefers.
252
254
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
253
255
  * @returns {boolean}
254
256
  */
255
257
  export function digestFlaggingEnabled(env = process.env) {
256
- return env[FLAG_DIGEST_VALUES_ENV] === "1";
258
+ return readKnob(FLAG_DIGEST_VALUES_ENV, env);
257
259
  }
258
260
 
259
261
  /**
@@ -83,22 +83,43 @@ export const FAULT_POLICY_HOOKS = Object.freeze([
83
83
 
84
84
  /**
85
85
  * The context a builder is handed: the caller's own inputs (whatever it passed
86
- * to {@link hookFaultOutcome}) plus the three values every hook derived by hand
86
+ * to {@link hookFaultOutcome}) plus the values every hook derived by hand
87
87
  * before — the hook name, the scrubbed error message, and the model-facing
88
- * open-posture warning.
88
+ * open-posture warning — and `pinnedClosed`, true when the policy's
89
+ * `alwaysClosed` overrode an open knob, so the closed arm can say why.
89
90
  * @typedef {Record<string, any> & {
90
91
  * hook: string,
91
92
  * err: unknown,
92
93
  * message: string,
93
94
  * openContext: string,
95
+ * pinnedClosed: boolean,
94
96
  * }} FaultContext
95
97
  */
96
98
 
97
99
  /**
98
- * A hook's declared posture.
100
+ * Whether a hook's faults are withheld whatever AGENT_SANITIZER_FAIL_OPEN says,
101
+ * for the caller's environment.
102
+ * @typedef {(
103
+ * env: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined,
104
+ * ) => boolean} AlwaysClosed
105
+ */
106
+
107
+ /**
108
+ * The `alwaysClosed` of a hook whose posture the knob alone decides.
109
+ * @type {AlwaysClosed}
110
+ */
111
+ export const KNOB_DECIDES = Object.freeze(() => false);
112
+
113
+ /**
114
+ * A hook's declared posture. `alwaysClosed` is REQUIRED, so every hook states
115
+ * on the record whether the knob can open it: {@link KNOB_DECIDES}, or a
116
+ * predicate that pins the CLOSED arm — how a guarantee the docs make
117
+ * unconditionally (Layer 4 withholds what it could not vet) is enforced in the
118
+ * one posture resolver rather than by a hook re-reading the knob.
99
119
  * @typedef {{
100
120
  * event: string | null,
101
121
  * guarded: string,
122
+ * alwaysClosed: AlwaysClosed,
102
123
  * open?: (ctx: FaultContext) => FaultParts,
103
124
  * closed: (ctx: FaultContext) => FaultParts,
104
125
  * }} FaultPolicy
@@ -120,6 +141,14 @@ export function registerFaultPolicy(hook, policy) {
120
141
  `unknown hook ${JSON.stringify(hook)}: add it to FAULT_POLICY_HOOKS in ` +
121
142
  "claude-hooks/lib/hook-fault.mjs before registering a posture for it",
122
143
  );
144
+ // Checked here, not at fault time: a posture that is incomplete must fail the
145
+ // import, never the one fault it was supposed to answer.
146
+ for (const arm of /** @type {const} */ (["alwaysClosed", "closed"]))
147
+ if (typeof policy[arm] !== "function")
148
+ throw new Error(
149
+ `fault policy for ${JSON.stringify(hook)} declares no \`${arm}\`; ` +
150
+ "every hook states it explicitly (KNOB_DECIDES for alwaysClosed)",
151
+ );
123
152
  policies.set(hook, policy);
124
153
  }
125
154
 
@@ -161,7 +190,8 @@ export function defaultOpen(ctx) {
161
190
  /**
162
191
  * Resolve `hook`'s response to its own failure under the caller's posture. This
163
192
  * is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
164
- * knob cannot be honored in three hooks and skipped in the fourth.
193
+ * knob cannot be honored in three hooks and skipped in the fourth — and the
194
+ * only place a policy's `alwaysClosed` can override it.
165
195
  * @param {string} hook
166
196
  * @param {unknown} err
167
197
  * @param {Record<string, any> & {
@@ -174,7 +204,9 @@ export function defaultOpen(ctx) {
174
204
  */
175
205
  export function hookFaultOutcome(hook, err, ctx = {}) {
176
206
  const policy = faultPolicy(hook);
177
- const open = failOpenEnabled(ctx.env);
207
+ const knobOpen = failOpenEnabled(ctx.env);
208
+ const pinnedClosed = knobOpen && policy.alwaysClosed(ctx.env);
209
+ const open = knobOpen && !pinnedClosed;
178
210
  /** @type {FaultContext} */
179
211
  const full = {
180
212
  ...ctx,
@@ -182,6 +214,7 @@ export function hookFaultOutcome(hook, err, ctx = {}) {
182
214
  err,
183
215
  message: safeErrMessage(err),
184
216
  openContext: failOpenContext(hook, policy.guarded, err),
217
+ pinnedClosed,
185
218
  };
186
219
  const parts = (open ? (policy.open ?? defaultOpen) : policy.closed)(full);
187
220
  const fields = parts.fields ?? null;
@@ -9,10 +9,10 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
  import { spawnSync } from "node:child_process";
12
- import { userInfo } from "node:os";
13
12
  import { createHash } from "node:crypto";
14
13
  import { pathToFileURL } from "node:url";
15
14
  import { excludeProvisioning } from "./hook-timing.mjs";
15
+ import { readKnob } from "./knobs.mjs";
16
16
 
17
17
  /**
18
18
  * EVERY process-wide slot these helpers keep — the four a host can observe or
@@ -212,20 +212,11 @@ export const PermissionDecision = Object.freeze({
212
212
  export const FAIL_OPEN_ENV = "AGENT_SANITIZER_FAIL_OPEN";
213
213
 
214
214
  /**
215
- * Values that turn the default posture back to fail-closed. Matched exactly:
216
- * a case-insensitive match would need `tr`, which the launcher cannot reach
217
- * (it runs its no-node arm on shell builtins alone).
218
- *
219
- * THE SINGLE SOURCE OF TRUTH for the closed set. The shell shims cannot import
220
- * it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
221
- * `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
222
- * plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
223
- * out by hand is an implementation that must appear in the parity table in
224
- * tests/test_safe_launch.py.
215
+ * Values that turn the default posture back to fail-closed. Defined beside the
216
+ * knob's grammar in lib/knobs.mjs and published here, where hosts and
217
+ * `scripts/gen-fail-open-lib.mjs` read it.
225
218
  */
226
- export const FAIL_CLOSED_VALUES = Object.freeze(["0", "false"]);
227
-
228
- const FAIL_CLOSED_SET = new Set(FAIL_CLOSED_VALUES);
219
+ export { FAIL_CLOSED_VALUES } from "./knobs.mjs";
229
220
 
230
221
  /**
231
222
  * Whether hook failures pass the guarded action through. True unless the caller
@@ -236,12 +227,13 @@ const FAIL_CLOSED_SET = new Set(FAIL_CLOSED_VALUES);
236
227
  * mistake is reversed: those default to the safe side, so an unrecognized value
237
228
  * there costs nothing, while here it leaves an operator who asked for
238
229
  * strictness without it. `"false"` is the one spelling reached for by reflex,
239
- * so it is honored; everything else (`""`, `"no"`, `"off"`) is the open posture.
230
+ * so it is honored; everything else (`""`, `"no"`, `"off"`) is the open posture,
231
+ * and a value that is neither a closed nor an open spelling is reported.
240
232
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
241
233
  * @returns {boolean}
242
234
  */
243
235
  export function failOpenEnabled(env = process.env) {
244
- return !FAIL_CLOSED_SET.has(env[FAIL_OPEN_ENV] ?? "");
236
+ return readKnob(FAIL_OPEN_ENV, env);
245
237
  }
246
238
 
247
239
  /**
@@ -275,7 +267,7 @@ export function disabledHooks(
275
267
  env = process.env,
276
268
  report = (message) => process.stderr.write(`${message}\n`),
277
269
  ) {
278
- const named = (env[DISABLED_HOOKS_ENV] ?? "")
270
+ const named = (readKnob(DISABLED_HOOKS_ENV, env) ?? "")
279
271
  .split(",")
280
272
  .map((name) => name.trim())
281
273
  .filter((name) => name !== "");
@@ -787,24 +779,70 @@ export function hookgateMarkerPath(
787
779
  export const SETUP_LOCK_DECLARATION = "flock";
788
780
 
789
781
  /**
790
- * Whether an exclusive lock is currently held on `markerPath`, or null when the
791
- * question cannot be answered here (no `flock(1)` — it is util-linux, absent from a
792
- * stock macOS — or an exit status neither "acquired" nor "busy").
793
- * @param {string} markerPath
782
+ * Whether an exclusive lock is currently held on the file open at `fd`, or null
783
+ * when the question cannot be answered here (no `flock(1)` — it is util-linux,
784
+ * absent from a stock macOS — or an exit status neither "acquired" nor "busy").
785
+ *
786
+ * The probe locks the DESCRIPTOR the caller already opened, never the path.
787
+ * `flock <path>` opens with O_CREAT, so a probe that lost the race against the
788
+ * writer's final unlink re-created the marker — empty, owned by us, declaring
789
+ * nothing — and every later reader took that husk for a live setup whose pid it
790
+ * could not parse: a 900-second wait on each cold hook and a gap notice silenced
791
+ * for the session. Locking the inherited fd cannot create anything, and it judges
792
+ * the same inode the caller just read. The child's lock lives on the shared open
793
+ * file description, so it is released when the caller closes `fd`.
794
+ * @param {number} fd
794
795
  * @returns {boolean | null}
795
796
  */
796
- function markerLockHeld(markerPath) {
797
- const probe = spawnSync(
798
- "flock",
799
- ["--nonblock", "--exclusive", markerPath, "true"],
800
- { stdio: "ignore" },
801
- );
797
+ function lockHeldOn(fd) {
798
+ const probe = spawnSync("flock", ["--nonblock", "--exclusive", "3"], {
799
+ stdio: ["ignore", "ignore", "ignore", fd],
800
+ });
802
801
  if (probe.error) return null;
803
802
  if (probe.status === 0) return false; // acquired: nobody holds it
804
803
  if (probe.status === 1) return true; // busy: the writer still holds it
805
804
  return null;
806
805
  }
807
806
 
807
+ /**
808
+ * A setup marker's trimmed lines and, when it declares
809
+ * {@link SETUP_LOCK_DECLARATION}, whether that lock is held (null: the
810
+ * declaration is absent, or no `flock(1)` could answer). Null when the marker
811
+ * cannot be opened or read — a missing marker included, which is how a probe
812
+ * that runs after the writer's unlink learns the install is over WITHOUT
813
+ * re-creating the file (see {@link lockHeldOn}).
814
+ *
815
+ * One descriptor serves both the read and the lock probe, so the two halves
816
+ * describe one inode: reading the path and then locking it by name can judge a
817
+ * marker the writer replaced in between.
818
+ * @param {string} markerPath
819
+ * @returns {{ lines: string[], lockHeld: boolean | null } | null}
820
+ */
821
+ function readSetupMarker(markerPath) {
822
+ let fd;
823
+ try {
824
+ fd = openSync(markerPath, "r");
825
+ } catch {
826
+ return null;
827
+ }
828
+ try {
829
+ let raw;
830
+ try {
831
+ raw = readFileSync(fd, "utf8");
832
+ } catch {
833
+ // EISDIR and kin: opened but unreadable, the same answer as unopenable.
834
+ return null;
835
+ }
836
+ const lines = raw.split("\n").map((line) => line.trim());
837
+ return {
838
+ lines,
839
+ lockHeld: lines.includes(SETUP_LOCK_DECLARATION) ? lockHeldOn(fd) : null,
840
+ };
841
+ } finally {
842
+ closeSync(fd);
843
+ }
844
+ }
845
+
808
846
  /**
809
847
  * Is a setup process DEMONSTRABLY running right now?
810
848
  *
@@ -827,18 +865,10 @@ function markerLockHeld(markerPath) {
827
865
  */
828
866
  export function setupRunning(markerPath) {
829
867
  if (!markerIsTrusted(markerPath)) return false;
830
- let raw;
831
- try {
832
- raw = readFileSync(/** @type {string} */ (markerPath), "utf8");
833
- } catch {
834
- return false;
835
- }
836
- const lines = raw.split("\n").map((line) => line.trim());
837
- if (lines.includes(SETUP_LOCK_DECLARATION)) {
838
- const held = markerLockHeld(/** @type {string} */ (markerPath));
839
- if (held !== null) return held;
840
- }
841
- const pid = parseInt(lines[0], 10);
868
+ const marker = readSetupMarker(/** @type {string} */ (markerPath));
869
+ if (marker === null) return false;
870
+ if (marker.lockHeld !== null) return marker.lockHeld;
871
+ const pid = parseInt(marker.lines[0], 10);
842
872
  if (!Number.isInteger(pid) || pid <= 0) return false;
843
873
  try {
844
874
  process.kill(pid, 0);
@@ -871,20 +901,12 @@ export function setupRunning(markerPath) {
871
901
  */
872
902
  export function probeSetupAlive(markerPath) {
873
903
  if (markerPath === null) return true;
874
- let raw;
875
- try {
876
- raw = readFileSync(markerPath, "utf8");
877
- } catch {
878
- return true;
879
- }
880
- const lines = raw.split("\n").map((line) => line.trim());
881
- if (lines.includes(SETUP_LOCK_DECLARATION)) {
882
- const held = markerLockHeld(markerPath);
883
- // null only: a host without flock(1) still gets the pid reading below rather
884
- // than an answer this probe cannot support.
885
- if (held !== null) return held;
886
- }
887
- const pid = parseInt(lines[0], 10);
904
+ const marker = readSetupMarker(markerPath);
905
+ if (marker === null) return true;
906
+ // null only: a host without flock(1) still gets the pid reading below rather
907
+ // than an answer this probe cannot support.
908
+ if (marker.lockHeld !== null) return marker.lockHeld;
909
+ const pid = parseInt(marker.lines[0], 10);
888
910
  if (!Number.isInteger(pid) || pid <= 0) return true;
889
911
  try {
890
912
  process.kill(pid, 0);
@@ -1022,7 +1044,64 @@ export function markerIsTrusted(path) {
1022
1044
  } catch {
1023
1045
  return false;
1024
1046
  }
1025
- return st.isFile() && st.uid === userInfo().uid;
1047
+ return st.isFile() && ownedByCurrentUser(st);
1048
+ }
1049
+
1050
+ /**
1051
+ * This process's real uid, or null on a platform with no POSIX uid model.
1052
+ *
1053
+ * `process.getuid()` and never `os.userInfo().uid`: userInfo() resolves the
1054
+ * uid through the passwd database and THROWS (ENOENT) when the uid has no entry
1055
+ * — the ordinary state of a container started with `--user 54321` — so every
1056
+ * ownership check built on it crashed the hook that asked, and a crashed hook
1057
+ * is non-blocking. The uid is a kernel fact; the passwd name is not needed.
1058
+ *
1059
+ * Null is explicit rather than the -1 userInfo() reports on Windows: callers
1060
+ * test ownership through {@link ownedByCurrentUser}, which reads null as "cannot
1061
+ * prove ownership" — the untrusted side every caller already handles.
1062
+ * @returns {number | null}
1063
+ */
1064
+ export function currentUid() {
1065
+ return typeof process.getuid === "function" ? process.getuid() : null;
1066
+ }
1067
+
1068
+ /**
1069
+ * Whether `st` (an lstat result) is owned by this process's uid. THE ownership
1070
+ * predicate for every $TMPDIR store these hooks trust — a second spelling is how
1071
+ * four call sites came to share the passwd-lookup crash {@link currentUid}
1072
+ * documents. False when the platform has no uid model: ownership that cannot be
1073
+ * proven is not granted. `uid` is a seam for a test that must model another
1074
+ * uid's file; production takes the default.
1075
+ * @param {{ uid: number }} st
1076
+ * @param {number | null} [uid]
1077
+ * @returns {boolean}
1078
+ */
1079
+ export function ownedByCurrentUser(st, uid = currentUid()) {
1080
+ return uid !== null && st.uid === uid;
1081
+ }
1082
+
1083
+ /**
1084
+ * Whether `st` (an lstat result) is a directory no untrusted uid can change: a
1085
+ * real directory (lstat, so a planted symlink is not one), owned by this uid —
1086
+ * or by root where `rootMayOwn` — with no group/other write bit. THE trust test
1087
+ * for every private directory these hooks keep in $TMPDIR. Write is the
1088
+ * property because creating, unlinking or replacing an entry needs write on its
1089
+ * parent: a co-tenant holding it can delete the finding a gate needs, swap a
1090
+ * reveal file, or rebind the redactor socket to a listener that redacts nothing.
1091
+ * Root ownership is strictly stronger than ours, so a deployment may hand a dir
1092
+ * to root (`rootMayOwn`); with no uid model nothing is trusted, root included.
1093
+ * @param {{ isDirectory(): boolean, uid: number, mode: number }} st
1094
+ * @param {{ uid?: number | null, rootMayOwn?: boolean }} [opts] `uid` as in
1095
+ * {@link ownedByCurrentUser}
1096
+ * @returns {boolean}
1097
+ */
1098
+ export function isPrivateDir(
1099
+ st,
1100
+ { uid = currentUid(), rootMayOwn = false } = {},
1101
+ ) {
1102
+ const owner =
1103
+ ownedByCurrentUser(st, uid) || (uid !== null && rootMayOwn && st.uid === 0);
1104
+ return st.isDirectory() && owner && (st.mode & 0o022) === 0;
1026
1105
  }
1027
1106
 
1028
1107
  /**
@@ -21,11 +21,12 @@ import {
21
21
  } from "node:fs";
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { basename, join } from "node:path";
24
- import { homedir, tmpdir, userInfo } from "node:os";
24
+ import { homedir, tmpdir } from "node:os";
25
25
  import {
26
26
  hookgateMarkerPath,
27
27
  lazyImport,
28
28
  markerIsTrusted,
29
+ isPrivateDir,
29
30
  probeSetupAlive,
30
31
  PROJECT_DIR,
31
32
  PROJECT_HASH,
@@ -205,11 +206,12 @@ function withinTtl(path, ttlMs) {
205
206
  const withinFallbackTtl = (path) => withinTtl(path, FALLBACK_TTL_MS);
206
207
 
207
208
  /**
208
- * Whether `path` is a real directory this uid owns — the directory counterpart
209
- * of markerIsTrusted. `lstat`, so a symlink planted at the predictable alert-dir
210
- * path is judged on ITSELF: followed, it would let a co-tenant aim the gate's
211
- * reader at a directory of unrelated files this uid owns and splice their bytes
212
- * into a permission prompt.
209
+ * Whether `path` is a private directory of this uid ({@link isPrivateDir}) —
210
+ * the directory counterpart of markerIsTrusted. `lstat`, so a symlink planted
211
+ * at the predictable alert-dir path is judged on ITSELF: followed, it would let
212
+ * a co-tenant aim the gate's reader at a directory of unrelated files this uid
213
+ * owns and splice their bytes into a permission prompt. A group/other-writable
214
+ * one is refused too: a co-tenant could unlink the finding the gate needs.
213
215
  * @param {string} path
214
216
  * @returns {boolean}
215
217
  */
@@ -220,7 +222,7 @@ function dirIsTrusted(path) {
220
222
  } catch {
221
223
  return false;
222
224
  }
223
- return st.isDirectory() && st.uid === userInfo().uid;
225
+ return isPrivateDir(st);
224
226
  }
225
227
 
226
228
  /**