agent-sanitizer 2.65.0 → 2.66.1

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 CHANGED
@@ -227,6 +227,11 @@ guessing if the marketplace was never added. To pull a release by hand instead:
227
227
 
228
228
  `plugin/README.md` has the managed-settings form for enabling it fleet-wide.
229
229
 
230
+ The plugin installs from this repository's `plugin-dist` branch, which CI
231
+ builds from each release tag; `main` carries the sources only, so a checkout
232
+ runs `pnpm install` (and `pnpm build:redactor` for the zipapp) to get a
233
+ runnable `plugin/`.
234
+
230
235
  To wire them yourself instead, one entry dispatches every mode on `--hook=`:
231
236
 
232
237
  ```jsonc
package/THREAT-MODEL.md CHANGED
@@ -853,31 +853,49 @@ sanitizes (see `plugin/scripts/provision-hook-binary.sh`). That is the
853
853
  product's only path that fetches an executable over the network and later runs
854
854
  it, so what anchors its trust is worth stating exactly.
855
855
 
856
- - **The anchor is in the repo, not on the wire.** `plugin/dist/hooks/hook-binaries.sha256`
857
- is committed, and the SessionStart provisioner refuses to install any download
858
- that does not hash to the digest it pins for that platform. A compromised or
859
- substituted release asset alone therefore cannot be installed: it fails the
860
- comparison, is deleted, and the launcher keeps degrading loudly through node.
861
- - **What the digest is worth rests on the compile being reproducible.** The
862
- committed digests are generated from the committed bundle, and the release
863
- recompiles and byte-compares them (`build-hook-binaries.mjs --check` in
864
- `release-hook-binaries.sh`), so binaries that do not reproduce the manifest are
865
- never uploaded. The manifest is refreshed on the default branch by
866
- `auto-version.yaml`, ahead of that release, so what it describes is the bundle
867
- the release ships.
868
- - **A manifest that has fallen behind its bundle installs nothing.** Between a
869
- merge and that refresh, the default branch pins binaries compiled from the
870
- previous bundle. The provisioner compares its own bundle against the manifest's
871
- `# bundle=` pin and refuses on a mismatch, so the window degrades to the node
872
- path rather than running a binary that is not the code the install ships. A
873
- binary provisioned before the window stays installed but loses its install
874
- stamp, and the launcher runs a binary whose stamp does not name the shipped
875
- manifest only on a host where no node is found — there an older sanitizer
876
- still beats an unsanitized call.
877
- - **Not covered: an attacker who can write BOTH the repository and the release.**
878
- There is no signature; the manifest is trusted because it arrives through the
879
- same reviewed, gated path as the rest of the plugin. Rewriting it is rewriting
880
- the plugin, which is outside this boundary.
856
+ - **The anchor travels with the plugin, not on the wire.**
857
+ `plugin/dist/hooks/hook-binaries.sha256` ships in the installed plugin, and the
858
+ SessionStart provisioner refuses to install any download that does not hash
859
+ to the digest it pins for that platform. A compromised or substituted release
860
+ asset alone therefore cannot be installed: it fails the comparison, is
861
+ deleted, and the launcher keeps degrading loudly through node.
862
+ - **The installed plugin is the `plugin-dist` branch, and no human reviews it.**
863
+ `.claude-plugin/marketplace.json` installs from `plugin-dist`, whose commits
864
+ `.github/workflows/plugin-dist.yaml` writes: it checks out a release tag (a
865
+ `main` commit that went through review and the required checks), builds the
866
+ bundle, the zipapp, the generated shell libs and the manifest, compiles the
867
+ binaries, uploads them only after `sha256sum --check` against that manifest
868
+ passes, and then pushes the build. The manifest and the binaries come from the
869
+ same run, so the digest proves the download is what that run built — not that
870
+ an independent rebuild agrees. What the user runs is therefore exactly as
871
+ trustworthy as whatever can write `plugin-dist`.
872
+ - **Who can write `plugin-dist`.** The workflow pushes with
873
+ `TEMPLATE_SYNC_TOKEN_ORG` (the stock `GITHUB_TOKEN` where that secret is
874
+ unset), and the push is fast-forward only. Pushes are restricted to that
875
+ identity by a branch ruleset on `refs/heads/plugin-dist` blocking creation,
876
+ update, deletion and non-fast-forward for everyone but the repository's admin
877
+ bypass actors. That ruleset is repository configuration, not code in this
878
+ tree, so nothing here creates it; `plugin-dist-ruleset.yaml` only detects
879
+ its absence, failing daily and on any marketplace change while no active
880
+ `update` rule covers the branch. Without it, any collaborator with write
881
+ access can push the branch and ship code to every installed user. With it, the residual writers are the
882
+ admins and anything that can run a workflow holding that secret — a workflow
883
+ edited onto a branch of this repository can read it, so write access to the
884
+ repository still reaches `plugin-dist` through Actions, as it reaches `main`'s
885
+ release path.
886
+ - **A manifest that disagrees with its bundle installs nothing.** The bake
887
+ refuses to stage a manifest whose `# bundle=` pin is not the bundle it built,
888
+ and the provisioner compares its own bundle against that pin and refuses on a
889
+ mismatch, so a hand-rebuilt bundle beside an older manifest degrades to the
890
+ node path rather than running a binary that is not the code the install ships.
891
+ A binary provisioned under an earlier release stays installed but loses its
892
+ install stamp, and the launcher runs a binary whose stamp does not name the
893
+ shipped manifest only on a host where no node is found — there an older
894
+ sanitizer still beats an unsanitized call.
895
+ - **Not covered: an attacker who can write `plugin-dist`, or the workflow that
896
+ writes it.** There is no signature; the manifest is trusted because it arrives
897
+ in the same commit as the rest of the plugin. Rewriting it is rewriting the
898
+ plugin, which is outside this boundary.
881
899
  - **Not covered: re-verification at exec time.** The digest is checked once, at
882
900
  install. The launcher then execs
883
901
  `${CLAUDE_PLUGIN_DATA}/hook-binary/agent-sanitizer-hooks` on every hook
@@ -901,12 +919,12 @@ it, so what anchors its trust is worth stating exactly.
901
919
  location.** The provisioned redactor venv (`${CLAUDE_PLUGIN_DATA}/venv` and its
902
920
  `bin/`, whose daemon receives every secret-bearing payload) is handed to the
903
921
  client only when `trusted_dir` vouches for both directories; otherwise the
904
- client redacts through the committed zipapp. The degraded-warning marker root
922
+ client redacts through the shipped zipapp. The degraded-warning marker root
905
923
  under `TMPDIR` is read only when this user owns it with owner-only write, so a
906
924
  squatted shared-temp entry cannot pre-suppress the warning that a call ran
907
925
  unsanitized.
908
926
  - **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
909
- a binary. That leaves the node path running the committed bundle and the
927
+ a binary. That leaves the node path running the shipped bundle and the
910
928
  compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
911
929
  well, and an operator's own `NODE_COMPILE_CACHE` is used unchanged.
912
930
 
@@ -1002,38 +1020,44 @@ up to the constant factors a parse pays, and one sort where findings or splice
1002
1020
  ranges arrive in an order the caller does not promise (`foldConfusables`,
1003
1021
  `mergeRanges`). The bound that matters is that nothing is _quadratic_ — a sort
1004
1022
  of the findings in a 1 MB paste is milliseconds, a quadratic rescan of it is
1005
- minutes — and quadratic is what the two gates below are calibrated to catch,
1023
+ minutes — and quadratic is what the two tiers below are built to catch,
1006
1024
  because neither can see what the other does.
1007
1025
 
1008
- **Static — every pattern.** `tests/test_redos_js_static_guard.py` drives
1009
- regexploit over an inventory that `scripts/extract-js-regexes.mjs` extracts by
1010
- walking the TypeScript AST of every shipped `.mjs` (not by matching regex source
1011
- with a regex), and fails on any pattern with super-linear backtracking. A
1012
- `RegExp(…)` built at runtime must be statically resolvable or carry a written
1013
- exemption, so a new dynamic pattern is a red build rather than a hole.
1014
- `tests/secrets/test_redos_static_guard.py` is the twin over the Python engine,
1015
- and `eslint-plugin-redos` reports the same class in the editor, before CI.
1016
-
1017
- **Empirical — everything the static gates cannot see.** Two shapes of quadratic
1018
- carry no backtracking at all, so no pattern analyzer will ever report them:
1019
-
1020
- - an **unanchored** pattern retried at every start offset, each attempt linear
1021
- and none of them backtracking — `(?=.*[A-Z])(?=.*[0-9])` against a long value
1022
- carrying neither class, or a `[^>]*$` tail rescanned from every `<`;
1023
- - work that is not a pattern at all — a string rebuilt once per finding, a
1024
- prefix re-sliced inside a loop, a re-walk of an already-walked node.
1025
-
1026
- `test/algorithmic-complexity.test.mjs` runs an entry point over an adversarial
1027
- input at four sizes spanning 8x and fits how its CPU cost grows — an exponent,
1028
- classified against the one pair of thresholds every growth gate shares
1029
- (`test/helpers/cpu-timing.mjs`, twin `tests/_growth.py`) — with a
1030
- known-quadratic specimen as the control that proves the harness can still see
1031
- one. Each case also asserts, at the size it measures, that
1032
- the input still reaches the code the case is about — a length gate answering
1033
- first is how such a case quietly degrades into timing nothing.
1034
- `tests/secrets/test_algorithmic_complexity.py` is the twin over the Python
1035
- engine. `test/hook-latency.test.mjs` covers the three blocking hook paths
1036
- instead, in cost per calibration pass, with one wall-clock ceiling.
1026
+ **Deterministic — every pattern, no clock.** `tests/test_redos_js_static_guard.py`
1027
+ drives regexploit over an inventory that `scripts/extract-js-regexes.mjs`
1028
+ extracts by walking the TypeScript AST of every shipped `.mjs` (not by matching
1029
+ regex source with a regex), and fails on any pattern with super-linear
1030
+ backtracking. A `RegExp(…)` built at runtime must be statically resolvable or
1031
+ carry a written exemption, so a new dynamic pattern is a red build rather than a
1032
+ hole. `tests/secrets/test_redos_static_guard.py` is the twin over the Python
1033
+ engine. In `pnpm lint`, `eslint-plugin-redos` (recheck) and
1034
+ `eslint-plugin-regexp`'s `no-super-linear-backtracking` and
1035
+ `no-super-linear-move` read every shipped regex literal — the last of them
1036
+ reports the shape that carries no backtracking at all, an **unanchored**
1037
+ pattern retried at every start offset (`(?=.*[A-Z])(?=.*[0-9])` against a long
1038
+ value carrying neither class). `test/regex-recheck.test.mjs` hands every
1039
+ pattern a shipped module actually compiles at runtime to recheck. Every recheck
1040
+ verdict runs with its wall-clock incubation and attack timeouts off, so it
1041
+ rests on step counts alone and no machine's speed can change it.
1042
+
1043
+ **Budgets — everything that is not a pattern.** Work that is not a regex at
1044
+ all — a string rebuilt once per finding, a prefix re-sliced inside a loop, a
1045
+ re-walk of an already-walked node, a parser's own look-back — no pattern
1046
+ analyzer will ever report. `test/algorithmic-complexity.test.mjs` runs each
1047
+ entry point ONCE, after a warm-up, over an adversarial input sized so the
1048
+ linear implementation costs tens to hundreds of milliseconds, against an
1049
+ absolute CPU ceiling at least ten times that reading (`assertUnderBudget` in
1050
+ `test/helpers/budget.mjs`, twin `tests/_budget.py`). A quadratic at that size
1051
+ overshoots the ceiling by another order of magnitude, so no runner is slow or
1052
+ busy enough to blur the two; a constant-factor slowdown passes, by design. Each
1053
+ case also asserts, at the size it measures, that the input still reaches the
1054
+ code the case is about — a length gate answering first is how such a case
1055
+ quietly degrades into timing nothing. `tests/secrets/test_algorithmic_complexity.py`
1056
+ is the twin over the Python engine. `test/hook-latency.test.mjs` holds the
1057
+ three blocking hook paths to absolute ceilings per 256 KB call, read
1058
+ uninstrumented in CI. Every test verdict in the repo that reads a clock goes
1059
+ through that one helper (`test/real-clock-thresholds.test.mjs` and its Python
1060
+ twin enforce it).
1037
1061
 
1038
1062
  **Idioms new code is held to.** Each is the fix for a defect one of the gates
1039
1063
  caught:
@@ -25,9 +25,9 @@
25
25
  * cannot reach (it runs its no-node arm on shell builtins alone).
26
26
  *
27
27
  * THE single source of truth for the closed set. The shell shims cannot import
28
- * it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
29
- * `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
30
- * plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
28
+ * it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it at build time
29
+ * by `scripts/gen-fail-open-lib.mjs`; plugin/test/fail-open-parity.test.mjs
30
+ * asserts the generated lib decides as failOpenEnabled(). Everything else that spells the set
31
31
  * out by hand is an implementation that must appear in the parity table in
32
32
  * tests/test_safe_launch.py.
33
33
  */
@@ -115,7 +115,7 @@ export const DEFAULT_SOCKET_PATH = resolveSocketPath(process.env, currentUid());
115
115
  * sets it to the provisioned venv's console script when one exists, and
116
116
  * tests point it at dead paths to drive the fail-closed arm; a fallback
117
117
  * from an explicit setting would turn those arms into silent passes).
118
- * 2. The committed self-contained zipapp (`plugin/dist/redactor/daemon.pyz`) —
118
+ * 2. The shipped self-contained zipapp (`plugin/dist/redactor/daemon.pyz`) —
119
119
  * the guaranteed floor: it needs only a python3 on PATH, so a cold start
120
120
  * with no venv and no network still redacts instead of failing closed on
121
121
  * every tool call. At `_AGENT_SANITIZER_REDACTOR_PYZ` when the launcher
@@ -129,7 +129,7 @@ function daemonCommand() {
129
129
  const configured = readKnob("_AGENT_SANITIZER_REDACTOR_DAEMON");
130
130
  if (configured) return [configured];
131
131
  // Inside the built bundle import.meta.url is .../plugin/dist/hooks/<bundle>,
132
- // so the committed zipapp sits one level up; from the raw sources no such
132
+ // so the shipped zipapp sits one level up; from the raw sources no such
133
133
  // sibling exists and the check falls through.
134
134
  const pyz =
135
135
  readKnob("_AGENT_SANITIZER_REDACTOR_PYZ") ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.65.0",
3
+ "version": "2.66.1",
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": {
@@ -62,11 +62,13 @@
62
62
  "esbuild": "0.28.1",
63
63
  "eslint": "10.4.0",
64
64
  "eslint-plugin-redos": "4.5.0",
65
+ "eslint-plugin-regexp": "3.3.1",
65
66
  "fast-check": "4.8.0",
66
67
  "globals": "17.6.0",
67
68
  "lint-staged": "^17.0.5",
68
69
  "micromark-extension-gfm-autolink-literal": "2.1.0",
69
70
  "prettier": "^3.0.0",
71
+ "recheck": "4.5.0",
70
72
  "rehype-parse": "9.0.1",
71
73
  "remark-gfm": "4.0.1",
72
74
  "smol-toml": "^1.7.1",
@@ -249,12 +251,13 @@
249
251
  "check": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
250
252
  "typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
251
253
  "build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
254
+ "build": "pnpm build:plugin && node plugin/scripts/build-redactor-pyz.mjs --if-missing",
255
+ "build:plugin": "node plugin/scripts/build-plugin.mjs",
256
+ "build:redactor": "node plugin/scripts/build-redactor-pyz.mjs",
252
257
  "gen:joining-type": "node scripts/gen-joining-type.mjs",
253
258
  "gen:unicode": "node scripts/gen-unicode.mjs",
254
259
  "gen:gfm-autolink": "node scripts/gen-gfm-autolink.mjs",
255
260
  "gen:unicode-properties": "node scripts/gen-unicode-properties.mjs",
256
- "gen:fail-open-lib": "node scripts/gen-fail-open-lib.mjs",
257
- "gen:node-floor-lib": "node scripts/gen-node-floor-lib.mjs",
258
261
  "gen:hook-binaries": "node plugin/scripts/build-hook-binaries.mjs",
259
262
  "lint": "eslint .",
260
263
  "test:mutation": "node scripts/mutate.mjs",
@@ -45,9 +45,9 @@ export function readKnob<K extends keyof Knobs>(name: K, env?: NodeJS.ProcessEnv
45
45
  * cannot reach (it runs its no-node arm on shell builtins alone).
46
46
  *
47
47
  * THE single source of truth for the closed set. The shell shims cannot import
48
- * it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
49
- * `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
50
- * plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
48
+ * it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it at build time
49
+ * by `scripts/gen-fail-open-lib.mjs`; plugin/test/fail-open-parity.test.mjs
50
+ * asserts the generated lib decides as failOpenEnabled(). Everything else that spells the set
51
51
  * out by hand is an implementation that must appear in the parity table in
52
52
  * tests/test_safe_launch.py.
53
53
  */