agent-sanitizer 2.58.10 → 2.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THREAT-MODEL.md +12 -7
- package/bin/sanitize-cli.mjs +4 -17
- package/claude-hooks/lib/authored-content.mjs +3 -2
- package/claude-hooks/lib/env-config.mjs +12 -10
- package/claude-hooks/lib/hook-fault.mjs +38 -5
- package/claude-hooks/lib/hook-io.mjs +133 -54
- package/claude-hooks/lib/invisible-alert.mjs +9 -7
- package/claude-hooks/lib/knobs.mjs +286 -0
- package/claude-hooks/lib/redactor-client.mjs +161 -126
- package/claude-hooks/lib/redactor-wire.mjs +145 -0
- package/claude-hooks/lib/reveal.mjs +5 -10
- package/claude-hooks/lib/trace.mjs +38 -9
- package/claude-hooks/plugin-hooks.mjs +2 -0
- package/claude-hooks/pretooluse-sanitize.mjs +8 -3
- package/claude-hooks/sanitize-output.mjs +39 -23
- package/claude-hooks/sanitize-user-prompt.mjs +2 -0
- package/claude-hooks/scan-invisible-chars.mjs +2 -0
- package/claude-hooks/scan-loaded-instructions.mjs +2 -0
- package/package.json +2 -1
- package/python/README.md +6 -3
- package/python/agent_sanitizer/secrets/data/redactor-protocol.json +17 -0
- package/src/instructions.mjs +6 -1
- package/types/claude-hooks/lib/env-config.d.mts +8 -7
- package/types/claude-hooks/lib/hook-fault.d.mts +74 -4
- package/types/claude-hooks/lib/hook-io.d.mts +55 -14
- package/types/claude-hooks/lib/knobs.d.mts +138 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +58 -37
- package/types/claude-hooks/lib/redactor-wire.d.mts +46 -0
- package/types/claude-hooks/lib/trace.d.mts +19 -4
- package/types/claude-hooks/sanitize-output.d.mts +5 -6
|
@@ -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
|
-
*
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
73
|
-
const 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} */ (
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
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}
|
|
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
|
|
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
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
// degraded redactor would otherwise be invisible to
|
|
283
|
-
// the
|
|
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,
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
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(
|
|
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.
|
|
1141
|
-
//
|
|
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.
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
package/src/instructions.mjs
CHANGED
|
@@ -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) =>
|
|
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.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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.
|
|
72
|
-
* for the reason {@link secretsEnabled} gives: a typo fails toward the
|
|
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
|
|
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
|
-
*
|
|
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
|