agent-sanitizer 2.34.15 → 2.35.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/THREAT-MODEL.md CHANGED
@@ -521,15 +521,48 @@ there. The `sgrNote` flag on a `./output` result means "nothing here rose above
521
521
  note", so a caller can show the quiet line instead of the banner; one warning
522
522
  anywhere in the walk clears it.
523
523
 
524
+ ## Provisioned hook binary (supply chain)
525
+
526
+ The hooks can run from a self-contained executable compiled with
527
+ `bun build --compile`, so a session whose `PATH` has no node at all still
528
+ sanitizes (see `plugin/scripts/provision-hook-binary.sh`). That is the
529
+ product's only path that fetches an executable over the network and later runs
530
+ it, so what anchors its trust is worth stating exactly.
531
+
532
+ - **The anchor is in the repo, not on the wire.** `plugin/dist/hooks/hook-binaries.sha256`
533
+ is committed, and the SessionStart provisioner refuses to install any download
534
+ that does not hash to the digest it pins for that platform. A compromised or
535
+ substituted release asset alone therefore cannot be installed: it fails the
536
+ comparison, is deleted, and the launcher keeps degrading loudly through node.
537
+ - **What the digest is worth rests on the compile being reproducible.** The
538
+ committed digests are generated from the committed bundle, and CI recompiles
539
+ and byte-compares them (`build-hook-binaries.mjs --check`), so a manifest that
540
+ does not describe the bundle in the same commit fails before release.
541
+ - **Not covered: an attacker who can write BOTH the repository and the release.**
542
+ There is no signature; the manifest is trusted because it arrives through the
543
+ same reviewed, gated path as the rest of the plugin. Rewriting it is rewriting
544
+ the plugin, which is outside this boundary.
545
+ - **Not covered: re-verification at exec time.** The digest is checked once, at
546
+ install. The launcher then execs
547
+ `${CLAUDE_PLUGIN_DATA}/hook-binary/agent-sanitizer-hooks` on every hook
548
+ invocation without re-hashing it — that would cost a ~100 MB read per tool
549
+ call. Anything able to write inside `CLAUDE_PLUGIN_DATA` can therefore run
550
+ code in the session, which is why the provisioner creates that directory mode
551
+ 700 and installs the binary mode 700. A user-owned data directory is the
552
+ assumption; a shared or world-writable one is not supported.
553
+ - **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never
554
+ runs a binary, leaving the node path exactly as it was.
555
+
524
556
  ## Failure posture (`AGENT_SANITIZER_FAIL_OPEN`)
525
557
 
526
558
  Installed as Claude Code hooks, these fail **open**: a hook that could not
527
559
  complete lets the guarded action through with a warning in `additionalContext`
528
560
  rather than blocking the session. `AGENT_SANITIZER_FAIL_OPEN=0` (or `false`)
529
561
  restores the fail-closed verdicts — block, ask, suppress. The posture covers
530
- every way a hook can fail: the launcher not starting (no `node`, missing or
531
- corrupt bundle), the package never loading, a payload that never parsed, and a
532
- layer that ran and threw.
562
+ every way a hook can fail: the launcher finding no runtime (no `node`, and the
563
+ provisioned hook binary missing, unexecutable or failing without a verdict),
564
+ a missing or corrupt bundle, the package never loading, a payload that never
565
+ parsed, and a layer that ran and threw.
533
566
 
534
567
  One carve-out: when the PreToolUse hook itself fails (redactor daemon down,
535
568
  package failed to load, a layer threw) and the call is a **write-shaped tool**
@@ -542,11 +575,12 @@ placeholder-bearing write is never the benign availability case the open default
542
575
  protects — the model can retry once the sanitizer recovers, or ask the user. The
543
576
  check is package-free (a literal-string test on the already-parsed payload), so
544
577
  it holds even when the failure IS the missing package. Two accepted gaps: a
545
- launcher-level failure (no `node`, corrupt bundle) never reaches the check — the
546
- launcher cannot inspect the payload and always warns and `Bash` is excluded
547
- even though shell redirection can also persist placeholder text, because command
548
- strings mention `[REDACTED` benignly far too often for the ask to hold
549
- precision. All other faults keep the open default, and
578
+ launcher-level failure (no `node`, corrupt bundle, a provisioned hook binary
579
+ that is missing, unexecutable or dies without a verdict) never reaches the
580
+ check the launcher cannot inspect the payload and always warns — and `Bash`
581
+ is excluded even though shell redirection can also persist placeholder text,
582
+ because command strings mention `[REDACTED` benignly far too often for the ask
583
+ to hold precision. All other faults keep the open default, and
550
584
  `AGENT_SANITIZER_FAIL_OPEN=0` behavior is unchanged.
551
585
 
552
586
  **The open default is not enforceable against content.** Several of those
@@ -158,19 +158,23 @@ export function slowHookNotice(
158
158
  * @param {string} stepName
159
159
  * @param {number} elapsedMs
160
160
  * @param {number} [thresholdMs]
161
+ * @param {string} [advice] step-specific speedup advice — the default fits the
162
+ * engine install; the hook-binary download passes its own, because telling a
163
+ * user mid-download that uv would help is advice about the wrong step
161
164
  * @returns {string | null}
162
165
  */
163
166
  export function slowProvisionNotice(
164
167
  stepName,
165
168
  elapsedMs,
166
169
  thresholdMs = SLOW_PROVISION_THRESHOLD_MS,
170
+ advice = "Installing uv makes it faster",
167
171
  ) {
168
172
  if (elapsedMs <= thresholdMs) return null;
169
173
  return (
170
174
  `agent-sanitizer PERFORMANCE: one-time setup (${stepName}) took ` +
171
175
  `${formatSeconds(elapsedMs)}s, over its ${formatSeconds(thresholdMs)}s budget — ` +
172
176
  "this is paid once per install, not per tool call, so the session is not slow from here on. " +
173
- `Installing uv makes it faster; if it happens on EVERY new session, report it at ${ISSUE_URL}.`
177
+ `${advice}; if it happens on EVERY new session, report it at ${ISSUE_URL}.`
174
178
  );
175
179
  }
176
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.34.15",
3
+ "version": "2.35.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": {
@@ -54,6 +54,7 @@
54
54
  "@types/node": "25.9.1",
55
55
  "acorn": "^8.18.0",
56
56
  "agent-sanitizer": "link:.",
57
+ "bun": "1.3.11",
57
58
  "c8": "11.0.0",
58
59
  "esbuild": "0.28.1",
59
60
  "eslint": "10.4.0",
@@ -220,6 +221,7 @@
220
221
  "gen:joining-type": "node scripts/gen-joining-type.mjs",
221
222
  "gen:fail-open-lib": "node scripts/gen-fail-open-lib.mjs",
222
223
  "gen:node-floor-lib": "node scripts/gen-node-floor-lib.mjs",
224
+ "gen:hook-binaries": "node plugin/scripts/build-hook-binaries.mjs",
223
225
  "lint": "eslint .",
224
226
  "test:mutation": "node scripts/mutate.mjs",
225
227
  "format": "prettier --write .",
@@ -69,9 +69,12 @@ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?
69
69
  * @param {string} stepName
70
70
  * @param {number} elapsedMs
71
71
  * @param {number} [thresholdMs]
72
+ * @param {string} [advice] step-specific speedup advice — the default fits the
73
+ * engine install; the hook-binary download passes its own, because telling a
74
+ * user mid-download that uv would help is advice about the wrong step
72
75
  * @returns {string | null}
73
76
  */
74
- export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number): string | null;
77
+ export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number, advice?: string): string | null;
75
78
  /**
76
79
  * Write the slow-hook notice to stderr and return it, or return null when the
77
80
  * run was within budget (writing nothing, so the quiet path stays quiet).