agent-sanitizer 2.65.0 → 2.66.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 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
 
@@ -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.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": {
@@ -249,12 +249,13 @@
249
249
  "check": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
250
250
  "typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
251
251
  "build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
252
+ "build": "pnpm build:plugin && node plugin/scripts/build-redactor-pyz.mjs --if-missing",
253
+ "build:plugin": "node plugin/scripts/build-plugin.mjs",
254
+ "build:redactor": "node plugin/scripts/build-redactor-pyz.mjs",
252
255
  "gen:joining-type": "node scripts/gen-joining-type.mjs",
253
256
  "gen:unicode": "node scripts/gen-unicode.mjs",
254
257
  "gen:gfm-autolink": "node scripts/gen-gfm-autolink.mjs",
255
258
  "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
259
  "gen:hook-binaries": "node plugin/scripts/build-hook-binaries.mjs",
259
260
  "lint": "eslint .",
260
261
  "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
  */