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
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
the
|
|
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
|
|
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
|
|
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
|
|
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
|
-
**
|
|
1009
|
-
regexploit over an inventory that `scripts/extract-js-regexes.mjs`
|
|
1010
|
-
walking the TypeScript AST of every shipped `.mjs` (not by matching
|
|
1011
|
-
with a regex), and fails on any pattern with super-linear
|
|
1012
|
-
`RegExp(…)` built at runtime must be statically resolvable or
|
|
1013
|
-
exemption, so a new dynamic pattern is a red build rather than a
|
|
1014
|
-
`tests/secrets/test_redos_static_guard.py` is the twin over the Python
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
|
29
|
-
* `scripts/gen-fail-open-lib.mjs
|
|
30
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
49
|
-
* `scripts/gen-fail-open-lib.mjs
|
|
50
|
-
*
|
|
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
|
*/
|