agent-sanitizer 2.10.0 → 2.11.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/README.md +20 -0
- package/claude-hooks/lib/env-config.mjs +96 -6
- package/claude-hooks/lib/hook-io.mjs +29 -2
- package/claude-hooks/scan-invisible-chars.mjs +18 -6
- package/package.json +1 -1
- package/python/agent_sanitizer/secrets/data/credential-names.json +2 -2
- package/src/html.mjs +21 -1
- package/src/invisible.mjs +10 -0
- package/src/layer1.mjs +11 -4
- package/types/claude-hooks/lib/env-config.d.mts +30 -5
- package/types/claude-hooks/lib/hook-io.d.mts +17 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +6 -2
- package/types/layer1.d.mts +8 -3
package/README.md
CHANGED
|
@@ -202,6 +202,26 @@ hook module, and every consumer waits on that path instead. `lib/control-plane`
|
|
|
202
202
|
resolves the marker at module scope, so a call that lands after that import
|
|
203
203
|
warns on stderr — it cannot steer the wait that already started.
|
|
204
204
|
|
|
205
|
+
**A host's own remedy can replace the packaged one in every fail-closed
|
|
206
|
+
reason.** Deep call sites (`lib/control-plane`'s missing-package throw) take no
|
|
207
|
+
remedy argument, so by default they can only say `pnpm install`. A host whose
|
|
208
|
+
install has one entry point calls `configureMissingPackageRemedy(text)` (from
|
|
209
|
+
`lib/hook-io`) — typically at its bundle entry — and every remedy-less
|
|
210
|
+
`missingPackageMessage`/`missingPackageError` states that text instead. An
|
|
211
|
+
explicit per-call remedy (including a per-gate `MESSAGES.remedy`) still wins,
|
|
212
|
+
and `null` restores the packaged wording. Keep it to a sentence: past ~260
|
|
213
|
+
characters it overruns the 300-char message budget.
|
|
214
|
+
|
|
215
|
+
**A host's own secret registry can drive the env-bound redaction set.**
|
|
216
|
+
`configureEnvConfigSource({ minSecretLen, extraVars })` (from `lib/env-config`)
|
|
217
|
+
replaces the placeholder floor and unions extra `[A-Z0-9_]` variable names into
|
|
218
|
+
`envBoundSecretVars()`, so a host that already declares its forwarded
|
|
219
|
+
credentials (and their length floor) in a registry of its own feeds the packaged
|
|
220
|
+
helpers from it instead of forking the module. Unset fields keep the package
|
|
221
|
+
derivation; `null` restores it entirely. A malformed source — a non-object, a
|
|
222
|
+
key the seam does not read, a bad field — throws on first use, inside the
|
|
223
|
+
consuming hook's fail-closed catch, never at configure time.
|
|
224
|
+
|
|
205
225
|
Hook internals are tuned by `_AGENT_SANITIZER_*` variables (redactor daemon
|
|
206
226
|
path/socket/timeouts, sanitize budget, trace channel, Layer-2 reveal dir). The
|
|
207
227
|
leading underscore marks them unstable — the supported surface is the `--hook=`
|
|
@@ -30,13 +30,101 @@ export function inferenceKeyVars() {
|
|
|
30
30
|
return inferenceKeys.vars;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Host-supplied secret-vocabulary source, consulted before the package configs.
|
|
35
|
+
* Null (the default) keeps the package derivation.
|
|
36
|
+
* @type {{ minSecretLen?: number, extraVars?: string[] } | null}
|
|
37
|
+
*/
|
|
38
|
+
let hostEnvConfigSource = null;
|
|
39
|
+
|
|
40
|
+
const HOST_SOURCE_LABEL = "configureEnvConfigSource";
|
|
41
|
+
|
|
42
|
+
const HOST_SOURCE_KEYS = ["minSecretLen", "extraVars"];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Adopt a host's own secret config in place of the package's: `minSecretLen`
|
|
46
|
+
* replaces the placeholder floor, and `extraVars` are unioned into the
|
|
47
|
+
* env-bound redaction set. This seam is what lets a host whose credential
|
|
48
|
+
* registry already names its forwarded secrets (and their length floor) drive
|
|
49
|
+
* the packaged helpers from that registry instead of forking this module —
|
|
50
|
+
* a fork is the drift channel that lets the two redaction sets silently
|
|
51
|
+
* disagree. Unset fields keep their package derivation; `undefined` and `null`
|
|
52
|
+
* both restore it entirely. Like the missing-package remedy seam there is no
|
|
53
|
+
* too-late window: the source is consulted at each helper call, never resolved
|
|
54
|
+
* at module scope, so a later call steers every later answer. A malformed
|
|
55
|
+
* source — a non-object, a key this seam does not read, a bad field — throws
|
|
56
|
+
* on first use, not here (see {@link hostSource}).
|
|
57
|
+
* @param {{ minSecretLen?: number, extraVars?: string[] } | null} source
|
|
58
|
+
* host config, or null to restore the package derivation
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
export function configureEnvConfigSource(source) {
|
|
62
|
+
hostEnvConfigSource = source ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The stored host source with its shape validated, or null when unconfigured.
|
|
67
|
+
* Shape errors — a non-object source, a key this seam does not read — throw
|
|
68
|
+
* HERE, on first use inside the consuming hook's fail-closed catch, never at
|
|
69
|
+
* configure time: a top-level throw in a bundle entry would kill the hook
|
|
70
|
+
* before that catch installs (fail OPEN), and silently ignoring the problem
|
|
71
|
+
* is worse — a typo'd `minSecretLength: 32` must not leave the helpers
|
|
72
|
+
* running at the package floor while the host believes it configured 32.
|
|
73
|
+
* @returns {{ minSecretLen?: number, extraVars?: string[] } | null}
|
|
74
|
+
*/
|
|
75
|
+
function hostSource() {
|
|
76
|
+
const source = hostEnvConfigSource;
|
|
77
|
+
if (source === null) return null;
|
|
78
|
+
if (typeof source !== "object")
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${HOST_SOURCE_LABEL}: source must be an object, got ${typeof source}`,
|
|
81
|
+
);
|
|
82
|
+
for (const key of Object.keys(source))
|
|
83
|
+
if (!HOST_SOURCE_KEYS.includes(key))
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${HOST_SOURCE_LABEL}: unknown key ${JSON.stringify(key)}; it reads ` +
|
|
86
|
+
`${HOST_SOURCE_KEYS.join(" and ")}`,
|
|
87
|
+
);
|
|
88
|
+
return source;
|
|
89
|
+
}
|
|
90
|
+
|
|
33
91
|
/**
|
|
34
92
|
* The placeholder floor: a candidate value shorter than this is too short to be a
|
|
35
|
-
* real secret and is skipped by the env-bound redaction pre-gate.
|
|
93
|
+
* real secret and is skipped by the env-bound redaction pre-gate. A malformed
|
|
94
|
+
* host floor throws rather than degrading to the package's — a host that set 32
|
|
95
|
+
* must not silently run at a laxer floor, and a non-positive one would admit
|
|
96
|
+
* empty placeholders as secrets.
|
|
36
97
|
* @returns {number}
|
|
37
98
|
*/
|
|
38
99
|
export function minEnvSecretLen() {
|
|
39
|
-
|
|
100
|
+
const hostLen = hostSource()?.minSecretLen;
|
|
101
|
+
if (hostLen === undefined) return inferenceKeys.min_secret_len;
|
|
102
|
+
if (!Number.isInteger(hostLen) || hostLen <= 0)
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${HOST_SOURCE_LABEL}: minSecretLen must be a positive integer, got ${JSON.stringify(hostLen)}`,
|
|
105
|
+
);
|
|
106
|
+
return hostLen;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The host's declared extra secret variable names, or throw. A malformed entry
|
|
111
|
+
* fails CLOSED, same contract as {@link extraSecretVars}: dropping it silently
|
|
112
|
+
* would leave the host believing a forwarded credential is masked while its
|
|
113
|
+
* value flows to the model verbatim.
|
|
114
|
+
* @returns {string[]}
|
|
115
|
+
*/
|
|
116
|
+
function hostExtraSecretVars() {
|
|
117
|
+
const vars = hostSource()?.extraVars;
|
|
118
|
+
if (vars === undefined) return [];
|
|
119
|
+
if (!Array.isArray(vars))
|
|
120
|
+
throw new Error(`${HOST_SOURCE_LABEL}: extraVars must be an array`);
|
|
121
|
+
for (const name of vars)
|
|
122
|
+
if (typeof name !== "string" || !EXTRA_TOKEN_RE.test(name))
|
|
123
|
+
throw new Error(
|
|
124
|
+
`${HOST_SOURCE_LABEL}: ${JSON.stringify(name)} is not a variable name ` +
|
|
125
|
+
"(expected [A-Z0-9_] names)",
|
|
126
|
+
);
|
|
127
|
+
return vars;
|
|
40
128
|
}
|
|
41
129
|
|
|
42
130
|
// A rendered vocabulary token. Restricting it to A-Z/0-9/_ is what lets the
|
|
@@ -239,10 +327,11 @@ export function extraSecretVars(env = process.env) {
|
|
|
239
327
|
|
|
240
328
|
/**
|
|
241
329
|
* The env-bound redaction set: the UNION of the inference keys, the curated host
|
|
242
|
-
* credentials, any credential-shaped var present in the environment,
|
|
243
|
-
* operator's declared extras
|
|
244
|
-
* (the sanitize-output pre-gate, the
|
|
245
|
-
* must mirror it exactly, else a
|
|
330
|
+
* credentials, any credential-shaped var present in the environment, the
|
|
331
|
+
* operator's declared extras, and the host's configured extras. The redactor
|
|
332
|
+
* binds the same union; every consumer (the sanitize-output pre-gate, the
|
|
333
|
+
* redactor client's per-request env snapshot) must mirror it exactly, else a
|
|
334
|
+
* credential value would never trip the daemon.
|
|
246
335
|
* @param {Record<string, string | undefined>} [env]
|
|
247
336
|
* @returns {string[]}
|
|
248
337
|
*/
|
|
@@ -253,6 +342,7 @@ export function envBoundSecretVars(env = process.env) {
|
|
|
253
342
|
...scrubbed.vars,
|
|
254
343
|
...dynamicSecretVars(env),
|
|
255
344
|
...extraSecretVars(env),
|
|
345
|
+
...hostExtraSecretVars(),
|
|
256
346
|
]),
|
|
257
347
|
];
|
|
258
348
|
}
|
|
@@ -241,6 +241,33 @@ export function failedLazyPackages() {
|
|
|
241
241
|
export const DEFAULT_MISSING_PACKAGE_REMEDY =
|
|
242
242
|
"reinstall the hook dependencies (pnpm install) and retry.";
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Host-supplied default remedy, replacing DEFAULT_MISSING_PACKAGE_REMEDY. Null
|
|
246
|
+
* (the default) keeps the package's wording.
|
|
247
|
+
* @type {string | null}
|
|
248
|
+
*/
|
|
249
|
+
let missingPackageRemedyOverride = null;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Adopt a host's own remedy as the default {@link missingPackageMessage} and
|
|
253
|
+
* {@link missingPackageError} state when their caller passes none. This refusal
|
|
254
|
+
* to hard-code the wording is what prevents a fail-closed reason whose remedy
|
|
255
|
+
* names a command the host doesn't have: deep call sites (controlPlane's
|
|
256
|
+
* missing-package throw) never take a remedy argument, so without this seam
|
|
257
|
+
* they can only ever tell a reader to run `pnpm install` — wrong advice in a
|
|
258
|
+
* host whose one install entry point is its own setup script. An explicit
|
|
259
|
+
* per-call remedy still wins over the configured one. Unlike the hookgate
|
|
260
|
+
* marker there is no too-late window: the remedy is consulted at each throw,
|
|
261
|
+
* never resolved at module scope, so a later call steers every later message.
|
|
262
|
+
* Keep it to a sentence — a remedy beyond ~260 characters overruns the 300-char
|
|
263
|
+
* message budget (see missingPackageMessage).
|
|
264
|
+
* @param {string | null} remedy host remedy text, or null to restore the package default
|
|
265
|
+
* @returns {void}
|
|
266
|
+
*/
|
|
267
|
+
export function configureMissingPackageRemedy(remedy) {
|
|
268
|
+
missingPackageRemedyOverride = remedy;
|
|
269
|
+
}
|
|
270
|
+
|
|
244
271
|
/**
|
|
245
272
|
* The fail-closed reason for a package a hook could not load: the recorded
|
|
246
273
|
* loader error plus the remedy. The cause is scrubbed (it is spliced into
|
|
@@ -257,7 +284,7 @@ export const DEFAULT_MISSING_PACKAGE_REMEDY =
|
|
|
257
284
|
export function missingPackageMessage(
|
|
258
285
|
pkg,
|
|
259
286
|
err = lazyImportErrorFor(pkg),
|
|
260
|
-
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
287
|
+
remedy = missingPackageRemedyOverride ?? DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
261
288
|
) {
|
|
262
289
|
const prefix = `${pkg} is unavailable: `;
|
|
263
290
|
// 2 for the "; " joiner; 12 for safeErrMessage's own "…[truncated]" marker,
|
|
@@ -287,7 +314,7 @@ export function missingPackageMessage(
|
|
|
287
314
|
export function missingPackageError(
|
|
288
315
|
pkg,
|
|
289
316
|
err = lazyImportErrorFor(pkg),
|
|
290
|
-
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
317
|
+
remedy = missingPackageRemedyOverride ?? DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
291
318
|
) {
|
|
292
319
|
return Object.assign(new Error(missingPackageMessage(pkg, err, remedy)), {
|
|
293
320
|
code: "DEP_UNAVAILABLE",
|
|
@@ -136,16 +136,28 @@ function findMdFiles(dir) {
|
|
|
136
136
|
* under `dir`. Claude Code loads these as project instructions on entry to their
|
|
137
137
|
* containing directory — a load path that bypasses the PostToolUse sanitizer — so
|
|
138
138
|
* a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
|
|
139
|
-
* unless it is scanned here. Skips node_modules
|
|
140
|
-
*
|
|
139
|
+
* unless it is scanned here. Skips node_modules.
|
|
140
|
+
*
|
|
141
|
+
* `**` does not descend into dot directories, so NESTED `.claude/` trees need
|
|
142
|
+
* their own pattern: the caller scans only the project-root `.claude`, which
|
|
143
|
+
* would leave a directory-scoped skill at `packages/foo/.claude/skills/x/SKILL.md`
|
|
144
|
+
* — model context by the same load path — never scanned.
|
|
141
145
|
* @param {string} dir
|
|
142
146
|
* @returns {string[]}
|
|
143
147
|
*/
|
|
144
148
|
function findInstructionFiles(dir) {
|
|
145
|
-
return globSync(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
return globSync(
|
|
150
|
+
[
|
|
151
|
+
"**/CLAUDE.md",
|
|
152
|
+
"**/CLAUDE.local.md",
|
|
153
|
+
"**/AGENTS.md",
|
|
154
|
+
"**/.claude/**/*.md",
|
|
155
|
+
],
|
|
156
|
+
{
|
|
157
|
+
cwd: dir,
|
|
158
|
+
exclude: (name) => name === "node_modules",
|
|
159
|
+
},
|
|
160
|
+
).map((name) => join(dir, name));
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
// Scanner
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.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": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials
|
|
2
|
+
"$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials` and `secrets` are env-name only — `credential`/`credentials` are everyday variable names, and an attribute chain (`credentials = service_account.Credentials.from_service_account_info(info)`) escapes every value-shape skip, so a field-value rendering would redact ordinary source lines. `passphrase` is the exception among them: it is as shape-specific as `password`, and detect-secrets' own KeywordDetector DENYLIST omits it, so name-only would let `passphrase = <secret>` reach the model in cleartext. `nonSecretSuffixes` are the trailing words that make a credential-shaped name hold a NON-secret (a key's identifier, the public half of a keypair), which a consumer must not redact. Every part is restricted to a-z0-9 so it carries no regex metacharacter; the accessors enforce that and fail closed on a violation, an empty list, or an unknown `uses` value. It sits inside the Python package because a wheel can only ship data under its package directory, while npm's `files`/`exports` can name any path — so ONE physical file backs both ecosystems and there is no copy to drift.",
|
|
3
3
|
"nouns": [
|
|
4
4
|
{ "parts": ["api", "key"], "uses": ["env-name", "field-value"] },
|
|
5
5
|
{ "parts": ["access", "key"], "uses": ["env-name", "field-value"] },
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{ "parts": ["authorization"], "uses": ["env-name", "field-value"] },
|
|
13
13
|
{ "parts": ["password"], "uses": ["env-name", "field-value"] },
|
|
14
14
|
{ "parts": ["passwd"], "uses": ["env-name", "field-value"] },
|
|
15
|
-
{ "parts": ["passphrase"], "uses": ["env-name"] },
|
|
15
|
+
{ "parts": ["passphrase"], "uses": ["env-name", "field-value"] },
|
|
16
16
|
{ "parts": ["bearer"], "uses": ["env-name", "field-value"] },
|
|
17
17
|
{ "parts": ["secret"], "uses": ["env-name", "field-value"] },
|
|
18
18
|
{ "parts": ["secrets"], "uses": ["env-name"] },
|
package/src/html.mjs
CHANGED
|
@@ -1069,6 +1069,23 @@ const VOID_ELEMENTS = new Set([
|
|
|
1069
1069
|
"wbr",
|
|
1070
1070
|
]);
|
|
1071
1071
|
|
|
1072
|
+
// Foreign-content roots. Inside SVG and MathML the HTML parser honours a
|
|
1073
|
+
// self-closing `/>` (it does not in HTML content), so such a tag opens and
|
|
1074
|
+
// closes in one node and the text after it is a sibling, not its child.
|
|
1075
|
+
const FOREIGN_ELEMENTS = new Set(["svg", "math"]);
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* True when `value` is a foreign-content tag that closed itself. Splicing it as
|
|
1079
|
+
* a balance region instead would run to the container's end and delete every
|
|
1080
|
+
* visible word after a decorative hidden `<svg/>`.
|
|
1081
|
+
* @param {string} tagName
|
|
1082
|
+
* @param {string} value
|
|
1083
|
+
* @returns {boolean}
|
|
1084
|
+
*/
|
|
1085
|
+
function isSelfClosedForeign(tagName, value) {
|
|
1086
|
+
return FOREIGN_ELEMENTS.has(tagName) && /\/\s*>\s*$/.test(value);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1072
1089
|
// Elements whose content is RAW TEXT / RCDATA / script data: parse5 recognizes
|
|
1073
1090
|
// NO markup inside them (a `<!…` is not a comment, a `<b>` is not a tag) until
|
|
1074
1091
|
// the matching end tag. The per-tag balance walk must model this or it would
|
|
@@ -1523,7 +1540,10 @@ function scanInlineChildren(node, text, ranges, warned) {
|
|
|
1523
1540
|
// A void element never emits a matching close, so a balance region
|
|
1524
1541
|
// would extend to the container end and splice out following visible
|
|
1525
1542
|
// text. Emit a single-node range instead (the source branch does too).
|
|
1526
|
-
|
|
1543
|
+
// A self-closed FOREIGN element behaves the same way: in SVG/MathML
|
|
1544
|
+
// content the HTML spec honours the `/>` flag, so the element closes
|
|
1545
|
+
// immediately and everything after it renders.
|
|
1546
|
+
if (VOID_ELEMENTS.has(tagName) || isSelfClosedForeign(tagName, value))
|
|
1527
1547
|
ranges.push({ start: base, end, kind: "hidden" });
|
|
1528
1548
|
else {
|
|
1529
1549
|
state.tag = tagName;
|
package/src/invisible.mjs
CHANGED
|
@@ -13,8 +13,18 @@ import { joiningType, isVirama } from "./joining-type.mjs";
|
|
|
13
13
|
import { isStandardizedVariant } from "./standardized-variants.mjs";
|
|
14
14
|
import { CF_CODEPOINTS } from "./cf-charset.mjs";
|
|
15
15
|
|
|
16
|
+
// Unicode's Variation_Selector property, whole: the FE00 run, the Mongolian
|
|
17
|
+
// free variation selectors, and the astral E0100 supplement. The Mongolian four
|
|
18
|
+
// are general category Mn, so \p{Cf} does not reach them and CF_CODEPOINTS
|
|
19
|
+
// carries only U+180E (the vowel SEPARATOR) — without them a run of U+180B..D
|
|
20
|
+
// renders as nothing, survives untouched, and counts as VISIBLE length, which
|
|
21
|
+
// also widens the preserved-joiner budget.
|
|
16
22
|
export const VS = [
|
|
17
23
|
...Array.from({ length: 16 }, (_, i) => 0xfe00 + i),
|
|
24
|
+
0x180b,
|
|
25
|
+
0x180c,
|
|
26
|
+
0x180d,
|
|
27
|
+
0x180f,
|
|
18
28
|
...Array.from({ length: 240 }, (_, i) => 0xe0100 + i),
|
|
19
29
|
]
|
|
20
30
|
.map((codePoint) => String.fromCodePoint(codePoint))
|
package/src/layer1.mjs
CHANGED
|
@@ -112,20 +112,27 @@ const ANSI_RE = new RegExp(`(?:${OSC_BRANCH}|${CSI_BRANCH})`, "gu");
|
|
|
112
112
|
export const LONE_SURROGATE_RE =
|
|
113
113
|
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
114
114
|
|
|
115
|
+
const MAX_ANSI_PASSES = 3;
|
|
116
|
+
|
|
115
117
|
/**
|
|
116
118
|
* Strip ANSI escape sequences to a fixed point. Removing one sequence can
|
|
117
119
|
* reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
|
|
118
120
|
* trailing `[0m` once the inner sequence is removed, forming a brand-new valid
|
|
119
|
-
* sequence the single pass would miss), so iterate
|
|
120
|
-
*
|
|
121
|
-
*
|
|
121
|
+
* sequence the single pass would miss), so iterate — but only a fixed few
|
|
122
|
+
* times. Bounding by the input's ESC count is quadratic on attacker-controlled
|
|
123
|
+
* text: `("\x1b[").repeat(n) + "m".repeat(n)` reconstitutes exactly ONE
|
|
124
|
+
* sequence per pass, so n full O(n) scans run, and 48 KB already costs seconds.
|
|
125
|
+
* The passes are not what makes Layer 1 safe — applyLayer1's residual
|
|
126
|
+
* CONTROL_INTRODUCER_RE sweep is, and it removes every ESC/C1 byte whatever
|
|
127
|
+
* survives here. Past the bound a reconstituted sequence therefore degrades to
|
|
128
|
+
* VISIBLE text rather than a hidden control, which is the fail-open direction.
|
|
122
129
|
* @param {string} input
|
|
123
130
|
* @returns {string}
|
|
124
131
|
*/
|
|
125
132
|
export function stripAnsiFully(input) {
|
|
126
133
|
let prev = input;
|
|
127
134
|
let out = prev.replace(ANSI_RE, "");
|
|
128
|
-
|
|
135
|
+
for (let pass = 1; pass < MAX_ANSI_PASSES && out !== prev; pass++) {
|
|
129
136
|
prev = out;
|
|
130
137
|
out = prev.replace(ANSI_RE, "");
|
|
131
138
|
}
|
|
@@ -4,9 +4,33 @@
|
|
|
4
4
|
* @returns {string[]}
|
|
5
5
|
*/
|
|
6
6
|
export function inferenceKeyVars(): string[];
|
|
7
|
+
/**
|
|
8
|
+
* Adopt a host's own secret config in place of the package's: `minSecretLen`
|
|
9
|
+
* replaces the placeholder floor, and `extraVars` are unioned into the
|
|
10
|
+
* env-bound redaction set. This seam is what lets a host whose credential
|
|
11
|
+
* registry already names its forwarded secrets (and their length floor) drive
|
|
12
|
+
* the packaged helpers from that registry instead of forking this module —
|
|
13
|
+
* a fork is the drift channel that lets the two redaction sets silently
|
|
14
|
+
* disagree. Unset fields keep their package derivation; `undefined` and `null`
|
|
15
|
+
* both restore it entirely. Like the missing-package remedy seam there is no
|
|
16
|
+
* too-late window: the source is consulted at each helper call, never resolved
|
|
17
|
+
* at module scope, so a later call steers every later answer. A malformed
|
|
18
|
+
* source — a non-object, a key this seam does not read, a bad field — throws
|
|
19
|
+
* on first use, not here (see {@link hostSource}).
|
|
20
|
+
* @param {{ minSecretLen?: number, extraVars?: string[] } | null} source
|
|
21
|
+
* host config, or null to restore the package derivation
|
|
22
|
+
* @returns {void}
|
|
23
|
+
*/
|
|
24
|
+
export function configureEnvConfigSource(source: {
|
|
25
|
+
minSecretLen?: number;
|
|
26
|
+
extraVars?: string[];
|
|
27
|
+
} | null): void;
|
|
7
28
|
/**
|
|
8
29
|
* The placeholder floor: a candidate value shorter than this is too short to be a
|
|
9
|
-
* real secret and is skipped by the env-bound redaction pre-gate.
|
|
30
|
+
* real secret and is skipped by the env-bound redaction pre-gate. A malformed
|
|
31
|
+
* host floor throws rather than degrading to the package's — a host that set 32
|
|
32
|
+
* must not silently run at a laxer floor, and a non-positive one would admit
|
|
33
|
+
* empty placeholders as secrets.
|
|
10
34
|
* @returns {number}
|
|
11
35
|
*/
|
|
12
36
|
export function minEnvSecretLen(): number;
|
|
@@ -63,10 +87,11 @@ export function dynamicSecretVars(env?: Record<string, string | undefined>): str
|
|
|
63
87
|
export function extraSecretVars(env?: Record<string, string | undefined>): string[];
|
|
64
88
|
/**
|
|
65
89
|
* The env-bound redaction set: the UNION of the inference keys, the curated host
|
|
66
|
-
* credentials, any credential-shaped var present in the environment,
|
|
67
|
-
* operator's declared extras
|
|
68
|
-
* (the sanitize-output pre-gate, the
|
|
69
|
-
* must mirror it exactly, else a
|
|
90
|
+
* credentials, any credential-shaped var present in the environment, the
|
|
91
|
+
* operator's declared extras, and the host's configured extras. The redactor
|
|
92
|
+
* binds the same union; every consumer (the sanitize-output pre-gate, the
|
|
93
|
+
* redactor client's per-request env snapshot) must mirror it exactly, else a
|
|
94
|
+
* credential value would never trip the daemon.
|
|
70
95
|
* @param {Record<string, string | undefined>} [env]
|
|
71
96
|
* @returns {string[]}
|
|
72
97
|
*/
|
|
@@ -79,6 +79,23 @@ export function lazyImportErrorFor(pkg: string): unknown;
|
|
|
79
79
|
* @returns {string[]}
|
|
80
80
|
*/
|
|
81
81
|
export function failedLazyPackages(): string[];
|
|
82
|
+
/**
|
|
83
|
+
* Adopt a host's own remedy as the default {@link missingPackageMessage} and
|
|
84
|
+
* {@link missingPackageError} state when their caller passes none. This refusal
|
|
85
|
+
* to hard-code the wording is what prevents a fail-closed reason whose remedy
|
|
86
|
+
* names a command the host doesn't have: deep call sites (controlPlane's
|
|
87
|
+
* missing-package throw) never take a remedy argument, so without this seam
|
|
88
|
+
* they can only ever tell a reader to run `pnpm install` — wrong advice in a
|
|
89
|
+
* host whose one install entry point is its own setup script. An explicit
|
|
90
|
+
* per-call remedy still wins over the configured one. Unlike the hookgate
|
|
91
|
+
* marker there is no too-late window: the remedy is consulted at each throw,
|
|
92
|
+
* never resolved at module scope, so a later call steers every later message.
|
|
93
|
+
* Keep it to a sentence — a remedy beyond ~260 characters overruns the 300-char
|
|
94
|
+
* message budget (see missingPackageMessage).
|
|
95
|
+
* @param {string | null} remedy host remedy text, or null to restore the package default
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
export function configureMissingPackageRemedy(remedy: string | null): void;
|
|
82
99
|
/**
|
|
83
100
|
* The fail-closed reason for a package a hook could not load: the recorded
|
|
84
101
|
* loader error plus the remedy. The cause is scrubbed (it is spliced into
|
|
@@ -29,8 +29,12 @@ export function findMdFiles(dir: string): string[];
|
|
|
29
29
|
* under `dir`. Claude Code loads these as project instructions on entry to their
|
|
30
30
|
* containing directory — a load path that bypasses the PostToolUse sanitizer — so
|
|
31
31
|
* a payload planted in e.g. `packages/foo/CLAUDE.md` reaches the model uncleaned
|
|
32
|
-
* unless it is scanned here. Skips node_modules
|
|
33
|
-
*
|
|
32
|
+
* unless it is scanned here. Skips node_modules.
|
|
33
|
+
*
|
|
34
|
+
* `**` does not descend into dot directories, so NESTED `.claude/` trees need
|
|
35
|
+
* their own pattern: the caller scans only the project-root `.claude`, which
|
|
36
|
+
* would leave a directory-scoped skill at `packages/foo/.claude/skills/x/SKILL.md`
|
|
37
|
+
* — model context by the same load path — never scanned.
|
|
34
38
|
* @param {string} dir
|
|
35
39
|
* @returns {string[]}
|
|
36
40
|
*/
|
package/types/layer1.d.mts
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
* Strip ANSI escape sequences to a fixed point. Removing one sequence can
|
|
3
3
|
* reconstitute another around it (a lone ESC left of `ESC[32m[0m` gains the
|
|
4
4
|
* trailing `[0m` once the inner sequence is removed, forming a brand-new valid
|
|
5
|
-
* sequence the single pass would miss), so iterate
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* sequence the single pass would miss), so iterate — but only a fixed few
|
|
6
|
+
* times. Bounding by the input's ESC count is quadratic on attacker-controlled
|
|
7
|
+
* text: `("\x1b[").repeat(n) + "m".repeat(n)` reconstitutes exactly ONE
|
|
8
|
+
* sequence per pass, so n full O(n) scans run, and 48 KB already costs seconds.
|
|
9
|
+
* The passes are not what makes Layer 1 safe — applyLayer1's residual
|
|
10
|
+
* CONTROL_INTRODUCER_RE sweep is, and it removes every ESC/C1 byte whatever
|
|
11
|
+
* survives here. Past the bound a reconstituted sequence therefore degrades to
|
|
12
|
+
* VISIBLE text rather than a hidden control, which is the fail-open direction.
|
|
8
13
|
* @param {string} input
|
|
9
14
|
* @returns {string}
|
|
10
15
|
*/
|