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 +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 +15 -33
- 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 +4 -1
- package/claude-hooks/sanitize-output.mjs +37 -21
- 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 +1 -1
- 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 +3 -15
- package/types/claude-hooks/lib/trace.d.mts +19 -4
- package/types/claude-hooks/sanitize-output.d.mts +5 -6
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`)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
843
|
-
|
|
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
|
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
|
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.
|
|
250
|
-
* for the reason {@link secretsEnabled} gives: a typo fails toward the
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
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
|
|
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 = (
|
|
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 `
|
|
791
|
-
* question cannot be answered here (no `flock(1)` — it is util-linux,
|
|
792
|
-
* stock macOS — or an exit status neither "acquired" nor "busy").
|
|
793
|
-
*
|
|
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
|
|
797
|
-
const probe = spawnSync(
|
|
798
|
-
"
|
|
799
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
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
|
|
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
|
|
209
|
-
* of markerIsTrusted. `lstat`, so a symlink planted
|
|
210
|
-
* path is judged on ITSELF: followed, it would let
|
|
211
|
-
* reader at a directory of unrelated files this uid
|
|
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
|
|
225
|
+
return isPrivateDir(st);
|
|
224
226
|
}
|
|
225
227
|
|
|
226
228
|
/**
|