agent-sanitizer 2.62.1 → 2.62.2
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 +14 -2
- package/claude-hooks/lib/knobs.mjs +1 -0
- package/claude-hooks/lib/redactor-client.mjs +9 -5
- package/package.json +1 -1
- package/src/instructions.mjs +12 -3
- package/src/invisible.mjs +32 -17
- package/types/claude-hooks/lib/knobs.d.mts +3 -0
- package/types/invisible.d.mts +4 -1
- package/types/src/invisible.d.mts +4 -1
package/THREAT-MODEL.md
CHANGED
|
@@ -823,7 +823,11 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
823
823
|
merge and that refresh, the default branch pins binaries compiled from the
|
|
824
824
|
previous bundle. The provisioner compares its own bundle against the manifest's
|
|
825
825
|
`# bundle=` pin and refuses on a mismatch, so the window degrades to the node
|
|
826
|
-
path rather than running a binary that is not the code the install ships.
|
|
826
|
+
path rather than running a binary that is not the code the install ships. A
|
|
827
|
+
binary provisioned before the window stays installed but loses its install
|
|
828
|
+
stamp, and the launcher runs a binary whose stamp does not name the shipped
|
|
829
|
+
manifest only on a host where no node is found — there an older sanitizer
|
|
830
|
+
still beats an unsanitized call.
|
|
827
831
|
- **Not covered: an attacker who can write BOTH the repository and the release.**
|
|
828
832
|
There is no signature; the manifest is trusted because it arrives through the
|
|
829
833
|
same reviewed, gated path as the rest of the plugin. Rewriting it is rewriting
|
|
@@ -842,11 +846,19 @@ it, so what anchors its trust is worth stating exactly.
|
|
|
842
846
|
per install instead of once per tool call. V8 does not treat a code cache as
|
|
843
847
|
untrusted input, so those files are code the session runs, exactly like the
|
|
844
848
|
binary above. The launcher creates the directory mode 700 and gates it with
|
|
845
|
-
`
|
|
849
|
+
`trusted_dir` — the same owner-only-write property, refused with a line on
|
|
846
850
|
stderr when the directory does not hold it — so the same user-owned data
|
|
847
851
|
directory is the assumption here too. It carries no digest and needs none:
|
|
848
852
|
anything able to write there can already run code per the bullet above. Nothing
|
|
849
853
|
is fetched for it, so it adds no supply-chain surface.
|
|
854
|
+
- **The same gate covers everything else the launcher takes from a shared
|
|
855
|
+
location.** The provisioned redactor venv (`${CLAUDE_PLUGIN_DATA}/venv` and its
|
|
856
|
+
`bin/`, whose daemon receives every secret-bearing payload) is handed to the
|
|
857
|
+
client only when `trusted_dir` vouches for both directories; otherwise the
|
|
858
|
+
client redacts through the committed zipapp. The degraded-warning marker root
|
|
859
|
+
under `TMPDIR` is read only when this user owns it with owner-only write, so a
|
|
860
|
+
squatted shared-temp entry cannot pre-suppress the warning that a call ran
|
|
861
|
+
unsanitized.
|
|
850
862
|
- **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
|
|
851
863
|
a binary. That leaves the node path running the committed bundle and the
|
|
852
864
|
compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
|
|
@@ -220,6 +220,7 @@ export const KNOBS = Object.freeze({
|
|
|
220
220
|
_AGENT_SANITIZER_TRACE_FILE: on("internal", text()),
|
|
221
221
|
_AGENT_SANITIZER_REDACTOR_SOCKET: on("internal", text()),
|
|
222
222
|
_AGENT_SANITIZER_REDACTOR_DAEMON: on("internal", text()),
|
|
223
|
+
_AGENT_SANITIZER_REDACTOR_PYZ: on("internal", text()),
|
|
223
224
|
_AGENT_SANITIZER_REDACTOR_WAIT_MS: on("internal", milliseconds(8000)),
|
|
224
225
|
_AGENT_SANITIZER_REDACTOR_REQUEST_MS: on("internal", milliseconds(20000)),
|
|
225
226
|
_AGENT_SANITIZER_SANITIZE_BUDGET_MS: on("internal", milliseconds(120000)),
|
|
@@ -60,10 +60,12 @@ export const DEFAULT_SOCKET_PATH =
|
|
|
60
60
|
* sets it to the provisioned venv's console script when one exists, and
|
|
61
61
|
* tests point it at dead paths to drive the fail-closed arm; a fallback
|
|
62
62
|
* from an explicit setting would turn those arms into silent passes).
|
|
63
|
-
* 2. The committed self-contained zipapp
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
63
|
+
* 2. The committed self-contained zipapp (`plugin/dist/redactor/daemon.pyz`) —
|
|
64
|
+
* the guaranteed floor: it needs only a python3 on PATH, so a cold start
|
|
65
|
+
* with no venv and no network still redacts instead of failing closed on
|
|
66
|
+
* every tool call. At `_AGENT_SANITIZER_REDACTOR_PYZ` when the launcher
|
|
67
|
+
* exports it (the compiled binary's module-relative path names no file on
|
|
68
|
+
* disk), else beside this bundle.
|
|
67
69
|
* 3. The packaged console script from PATH — the un-bundled/dev case, where
|
|
68
70
|
* no sibling dist tree exists.
|
|
69
71
|
* @returns {string[]}
|
|
@@ -74,7 +76,9 @@ function daemonCommand() {
|
|
|
74
76
|
// Inside the built bundle import.meta.url is .../plugin/dist/hooks/<bundle>,
|
|
75
77
|
// so the committed zipapp sits one level up; from the raw sources no such
|
|
76
78
|
// sibling exists and the check falls through.
|
|
77
|
-
const pyz =
|
|
79
|
+
const pyz =
|
|
80
|
+
readKnob("_AGENT_SANITIZER_REDACTOR_PYZ") ||
|
|
81
|
+
fileURLToPath(new URL("../redactor/daemon.pyz", import.meta.url));
|
|
78
82
|
if (existsSync(pyz)) return ["python3", pyz];
|
|
79
83
|
return ["agent-secret-redactor-daemon"];
|
|
80
84
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.62.
|
|
3
|
+
"version": "2.62.2",
|
|
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": {
|
package/src/instructions.mjs
CHANGED
|
@@ -232,11 +232,20 @@ export function scanText(content) {
|
|
|
232
232
|
let line = 1;
|
|
233
233
|
let counted = 0;
|
|
234
234
|
// The verdict keeps only the first run; the rest come off the payload view,
|
|
235
|
-
// which is index-aligned with `content` and masks preserved invisibles.
|
|
235
|
+
// which is index-aligned with `content` and masks preserved invisibles. The
|
|
236
|
+
// search resumes past the first run rather than matching it a second time,
|
|
237
|
+
// which on one 256 KB payload run is a regex pass over the whole file.
|
|
238
|
+
const { firstRun } = verdict;
|
|
236
239
|
const runs =
|
|
237
|
-
|
|
240
|
+
firstRun === null
|
|
238
241
|
? []
|
|
239
|
-
:
|
|
242
|
+
: [
|
|
243
|
+
firstRun,
|
|
244
|
+
...findLongRuns(
|
|
245
|
+
payloadInvisibleView(content),
|
|
246
|
+
firstRun.index + firstRun.text.length,
|
|
247
|
+
),
|
|
248
|
+
];
|
|
240
249
|
for (const run of runs) {
|
|
241
250
|
for (; counted < run.index; counted++)
|
|
242
251
|
if (content.charCodeAt(counted) === NEWLINE) line++;
|
package/src/invisible.mjs
CHANGED
|
@@ -218,14 +218,16 @@ const RUN_TAIL_RE = new RegExp(`(?:${STRIP.source}){1,${RUN_CHUNK}}`, "yu");
|
|
|
218
218
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
219
219
|
* runs reported are maximal at any length.
|
|
220
220
|
* @param {string} text
|
|
221
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
222
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
221
223
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
222
224
|
*/
|
|
223
|
-
export function* findLongRuns(text) {
|
|
225
|
+
export function* findLongRuns(text, from = 0) {
|
|
224
226
|
// Both regexes are module-level and carry `lastIndex`, and a generator can be
|
|
225
227
|
// suspended anywhere — including inside another scan of another text. Every
|
|
226
228
|
// exec below therefore sets its own start position first, so no scan can
|
|
227
229
|
// inherit a position from one it interleaved with.
|
|
228
|
-
let pos =
|
|
230
|
+
let pos = from;
|
|
229
231
|
for (;;) {
|
|
230
232
|
LONG_RUN_CHUNK_RE.lastIndex = pos;
|
|
231
233
|
const match = LONG_RUN_CHUNK_RE.exec(text);
|
|
@@ -1231,12 +1233,11 @@ function markTagSequences(cps) {
|
|
|
1231
1233
|
return keep;
|
|
1232
1234
|
}
|
|
1233
1235
|
|
|
1234
|
-
// The
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
...STANDARDIZED_VARIANTS.map(([, selector]) => selector),
|
|
1236
|
+
// The selectors the registered sequences use: a cheap pre-filter. Not their
|
|
1237
|
+
// span — U+180B to U+FE0D also holds U+200B and most other Cf code points, so a
|
|
1238
|
+
// zero-width run paid the keyed sequence lookup once per character.
|
|
1239
|
+
const STANDARDIZED_SELECTORS = new Set(
|
|
1240
|
+
STANDARDIZED_VARIANTS.map(([, selector]) => selector),
|
|
1240
1241
|
);
|
|
1241
1242
|
|
|
1242
1243
|
/**
|
|
@@ -1249,8 +1250,7 @@ const STANDARDIZED_SELECTOR_MAX = Math.max(
|
|
|
1249
1250
|
*/
|
|
1250
1251
|
function isStandardizedVariationSelector(cps, i) {
|
|
1251
1252
|
const cp = cps[i];
|
|
1252
|
-
if (cp
|
|
1253
|
-
return false;
|
|
1253
|
+
if (!STANDARDIZED_SELECTORS.has(cp)) return false;
|
|
1254
1254
|
return i > 0 && isStandardizedVariant(cps[i - 1], cp);
|
|
1255
1255
|
}
|
|
1256
1256
|
|
|
@@ -1892,7 +1892,7 @@ function carveWalk(body, cps, analysis, build) {
|
|
|
1892
1892
|
* @typedef {{ body: string, cps: Int32Array,
|
|
1893
1893
|
* analysis: ReturnType<typeof analyzeCarve>,
|
|
1894
1894
|
* strip: { cleaned: string, found: string[], removed: number } | null,
|
|
1895
|
-
* removed: number | null }} CarveEntry
|
|
1895
|
+
* removed: number | null, view: string | null }} CarveEntry
|
|
1896
1896
|
*/
|
|
1897
1897
|
|
|
1898
1898
|
/** @type {CarveEntry | null} */
|
|
@@ -1900,9 +1900,9 @@ let lastCarve = null;
|
|
|
1900
1900
|
|
|
1901
1901
|
/**
|
|
1902
1902
|
* The carve analysis of `body`, with the most recent one remembered — and its
|
|
1903
|
-
* strip, or
|
|
1904
|
-
* document several times (the payload view, the effective count,
|
|
1905
|
-
* {@link isIncidentalInvisible}); every reading is a pure function of `body`,
|
|
1903
|
+
* strip, removal count or payload view, once asked for. One hook call often
|
|
1904
|
+
* carves one document several times (the payload view, the effective count,
|
|
1905
|
+
* then {@link isIncidentalInvisible}); every reading is a pure function of `body`,
|
|
1906
1906
|
* so the cache changes what a verdict costs, never the verdict. One entry,
|
|
1907
1907
|
* recorded only after the analysis returns.
|
|
1908
1908
|
* @param {string} body
|
|
@@ -1912,7 +1912,7 @@ function carveOf(body) {
|
|
|
1912
1912
|
if (lastCarve !== null && lastCarve.body === body) return lastCarve;
|
|
1913
1913
|
const cps = codePointArray(body);
|
|
1914
1914
|
const analysis = analyzeCarve(cps);
|
|
1915
|
-
lastCarve = { body, cps, analysis, strip: null, removed: null };
|
|
1915
|
+
lastCarve = { body, cps, analysis, strip: null, removed: null, view: null };
|
|
1916
1916
|
return lastCarve;
|
|
1917
1917
|
}
|
|
1918
1918
|
|
|
@@ -1952,10 +1952,13 @@ function needsCarveOut(body) {
|
|
|
1952
1952
|
* @returns {string}
|
|
1953
1953
|
*/
|
|
1954
1954
|
export function payloadInvisibleView(text) {
|
|
1955
|
+
const entry = carveOf(text);
|
|
1956
|
+
// The verdict's run search and a caller listing every run both read it.
|
|
1957
|
+
if (entry.view !== null) return entry.view;
|
|
1955
1958
|
const {
|
|
1956
1959
|
cps,
|
|
1957
1960
|
analysis: { codes, kind },
|
|
1958
|
-
} =
|
|
1961
|
+
} = entry;
|
|
1959
1962
|
const offsets = u16Offsets(cps);
|
|
1960
1963
|
/** @param {number} i */
|
|
1961
1964
|
const isPayload = (i) => codes[i] !== CODE_VISIBLE && kind[i] === KIND_NONE;
|
|
@@ -1974,7 +1977,8 @@ export function payloadInvisibleView(text) {
|
|
|
1974
1977
|
while (i < cps.length && isPayload(i)) i++;
|
|
1975
1978
|
if (i > keepFrom) parts.push(text.slice(offsets[keepFrom], offsets[i]));
|
|
1976
1979
|
}
|
|
1977
|
-
|
|
1980
|
+
entry.view = parts.join("");
|
|
1981
|
+
return entry.view;
|
|
1978
1982
|
}
|
|
1979
1983
|
|
|
1980
1984
|
/**
|
|
@@ -2065,6 +2069,17 @@ export function countEffectiveInvisible(text) {
|
|
|
2065
2069
|
// Otherwise both terms read ONE carve analysis (see carveOf).
|
|
2066
2070
|
const entry = carveOf(text);
|
|
2067
2071
|
const payload = entry.analysis.payloadInvis;
|
|
2072
|
+
// When every invisible is payload, nothing is preservable, so either strip
|
|
2073
|
+
// removes exactly the invisibles and the surplus is zero — unless a removal
|
|
2074
|
+
// closes a lone high surrogate against a lone low one, which the code-point
|
|
2075
|
+
// gap counts too (see carveWalk's `removed`) and which needs a lone low one.
|
|
2076
|
+
// A hidden run or a scatter of soft hyphens lands here, and answering off the
|
|
2077
|
+
// analysis spares it a whole-text strip it would only measure.
|
|
2078
|
+
if (
|
|
2079
|
+
payload === entry.analysis.invisible.length &&
|
|
2080
|
+
!LONE_LOW_SURROGATE_RE.test(text)
|
|
2081
|
+
)
|
|
2082
|
+
return payload;
|
|
2068
2083
|
if (!needsCarveOut(text))
|
|
2069
2084
|
return surplusOver(
|
|
2070
2085
|
payload,
|
|
@@ -93,6 +93,9 @@ export const KNOBS: Readonly<{
|
|
|
93
93
|
_AGENT_SANITIZER_REDACTOR_DAEMON: Readonly<Knob<string | undefined> & {
|
|
94
94
|
surface: Surface;
|
|
95
95
|
}>;
|
|
96
|
+
_AGENT_SANITIZER_REDACTOR_PYZ: Readonly<Knob<string | undefined> & {
|
|
97
|
+
surface: Surface;
|
|
98
|
+
}>;
|
|
96
99
|
_AGENT_SANITIZER_REDACTOR_WAIT_MS: Readonly<Knob<number> & {
|
|
97
100
|
surface: Surface;
|
|
98
101
|
}>;
|
package/types/invisible.d.mts
CHANGED
|
@@ -28,9 +28,11 @@ export function isSgrOnly(text: string): boolean;
|
|
|
28
28
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
29
29
|
* runs reported are maximal at any length.
|
|
30
30
|
* @param {string} text
|
|
31
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
32
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
31
33
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
32
34
|
*/
|
|
33
|
-
export function findLongRuns(text: string): Generator<{
|
|
35
|
+
export function findLongRuns(text: string, from?: number): Generator<{
|
|
34
36
|
index: number;
|
|
35
37
|
text: string;
|
|
36
38
|
charCount: number;
|
|
@@ -252,6 +254,7 @@ export type CarveEntry = {
|
|
|
252
254
|
removed: number;
|
|
253
255
|
} | null;
|
|
254
256
|
removed: number | null;
|
|
257
|
+
view: string | null;
|
|
255
258
|
};
|
|
256
259
|
/**
|
|
257
260
|
* Per-invisible carve-out analysis, shared by carveStrip and
|
|
@@ -28,9 +28,11 @@ export function isSgrOnly(text: string): boolean;
|
|
|
28
28
|
* that hits the bound is continued by {@link RUN_TAIL_RE} until it ends, so the
|
|
29
29
|
* runs reported are maximal at any length.
|
|
30
30
|
* @param {string} text
|
|
31
|
+
* @param {number} [from] UTF-16 offset to resume at: the end of a run already
|
|
32
|
+
* taken off this text, so a caller holding the first run need not re-measure it
|
|
31
33
|
* @returns {Generator<{ index: number, text: string, charCount: number }>}
|
|
32
34
|
*/
|
|
33
|
-
export function findLongRuns(text: string): Generator<{
|
|
35
|
+
export function findLongRuns(text: string, from?: number): Generator<{
|
|
34
36
|
index: number;
|
|
35
37
|
text: string;
|
|
36
38
|
charCount: number;
|
|
@@ -252,6 +254,7 @@ export type CarveEntry = {
|
|
|
252
254
|
removed: number;
|
|
253
255
|
} | null;
|
|
254
256
|
removed: number | null;
|
|
257
|
+
view: string | null;
|
|
255
258
|
};
|
|
256
259
|
/**
|
|
257
260
|
* Per-invisible carve-out analysis, shared by carveStrip and
|