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 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
- `trusted_exec_dir` — the same owner-only-write property, refused with a line on
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 shipped BESIDE this bundle
64
- * (`plugin/dist/redactor/daemon.pyz`) — the guaranteed floor: it needs only
65
- * a python3 on PATH, so a cold start with no venv and no network still
66
- * redacts instead of failing closed on every tool call.
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 = fileURLToPath(new URL("../redactor/daemon.pyz", import.meta.url));
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.1",
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": {
@@ -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
- verdict.firstRun === null
240
+ firstRun === null
238
241
  ? []
239
- : findLongRuns(payloadInvisibleView(content));
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 = 0;
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 range the registered sequences' selectors span: a cheap pre-filter.
1235
- const STANDARDIZED_SELECTOR_MIN = Math.min(
1236
- ...STANDARDIZED_VARIANTS.map(([, selector]) => selector),
1237
- );
1238
- const STANDARDIZED_SELECTOR_MAX = Math.max(
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 < STANDARDIZED_SELECTOR_MIN || cp > STANDARDIZED_SELECTOR_MAX)
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 removal count, once asked for. One hook call often carves one
1904
- * document several times (the payload view, the effective count, then
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
- } = carveOf(text);
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
- return parts.join("");
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
  }>;
@@ -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