agent-sanitizer 2.47.1 → 2.47.3

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
@@ -669,8 +669,21 @@ it, so what anchors its trust is worth stating exactly.
669
669
  code in the session, which is why the provisioner creates that directory mode
670
670
  700 and installs the binary mode 700. A user-owned data directory is the
671
671
  assumption; a shared or world-writable one is not supported.
672
- - **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never
673
- runs a binary, leaving the node path exactly as it was.
672
+ - **A second artifact under that directory is loaded as code: V8's compile
673
+ cache.** The launcher points node at
674
+ `${CLAUDE_PLUGIN_DATA}/node-compile-cache` so the hook bundle is compiled once
675
+ per install instead of once per tool call. V8 does not treat a code cache as
676
+ untrusted input, so those files are code the session runs, exactly like the
677
+ binary above. The launcher creates the directory mode 700 and gates it with
678
+ `trusted_exec_dir` — the same owner-only-write property, refused with a line on
679
+ stderr when the directory does not hold it — so the same user-owned data
680
+ directory is the assumption here too. It carries no digest and needs none:
681
+ anything able to write there can already run code per the bullet above. Nothing
682
+ is fetched for it, so it adds no supply-chain surface.
683
+ - **Opting out.** `AGENT_SANITIZER_HOOK_BINARY=0` never downloads and never runs
684
+ a binary. That leaves the node path running the committed bundle and the
685
+ compile cache above; `AGENT_SANITIZER_COMPILE_CACHE=0` turns the cache off as
686
+ well, and an operator's own `NODE_COMPILE_CACHE` is used unchanged.
674
687
 
675
688
  ## Failure posture (`AGENT_SANITIZER_FAIL_OPEN`)
676
689
 
@@ -81,7 +81,8 @@ const HOOK_NAME = "pretooluse-sanitize";
81
81
  * must be blocked, or null to let the pipeline continue. Hosts use these for
82
82
  * policy the package has no view of (a required workflow step, a project-local
83
83
  * rule); the package ships none.
84
- * @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string })
84
+ * @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string,
85
+ * permission_mode?: string })
85
86
  * => string | null | undefined} HostGate
86
87
  */
87
88
 
@@ -595,13 +596,16 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
595
596
  // a pass is the one incentive a gate must never create.
596
597
  if (event.event === EventKind.UNKNOWN)
597
598
  return { decision: Decision.DENY, reason: messages.unknownEvent };
598
- // The session identity travels in `meta`, not alongside the tool input; a host
599
- // gate keyed on the session (a once-per-session checkpoint) cannot tell two
600
- // sessions apart without it.
599
+ // The session identity and the permission mode travel in `meta`, not alongside
600
+ // the tool input. A gate keyed on the session (a once-per-session checkpoint)
601
+ // cannot tell two sessions apart without the first, and a gate keyed on the
602
+ // mode reads `undefined` without the second — so it fires in EVERY mode, which
603
+ // is the safe direction but not the intended one.
601
604
  const input = {
602
605
  tool_name: event.tool,
603
606
  tool_input: event.input,
604
607
  session_id: event.meta?.session_id,
608
+ permission_mode: event.meta?.permission_mode,
605
609
  };
606
610
  // Host gates run BEFORE any rewriting layer, because they decide whether the
607
611
  // call may happen at all rather than what its input contains — and returning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.47.1",
3
+ "version": "2.47.3",
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": {
package/src/invisible.mjs CHANGED
@@ -1084,9 +1084,20 @@ function isPreservedBlankFiller(cps, i) {
1084
1084
  // defines the unit the preserve budget is charged against (see carveStrip).
1085
1085
  // Grapheme segmentation is locale-independent, so the locale is pinned to "en"
1086
1086
  // only for determinism across hosts.
1087
- const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", {
1088
- granularity: "grapheme",
1089
- });
1087
+ //
1088
+ // Built on FIRST USE, never at import: constructing it loads ICU's segmentation
1089
+ // tables and costs ~10 ms, the largest single item in this module's setup, and
1090
+ // only text with carve candidates ever reaches clusterResolver. The hooks that
1091
+ // import this run on every tool call, so building it at import charged every
1092
+ // call for a segmenter almost none of them use.
1093
+ /** @type {Intl.Segmenter | null} */
1094
+ let segmenterCache = null;
1095
+
1096
+ /** @returns {Intl.Segmenter} the process-wide grapheme segmenter. */
1097
+ function graphemeSegmenter() {
1098
+ segmenterCache ??= new Intl.Segmenter("en", { granularity: "grapheme" });
1099
+ return segmenterCache;
1100
+ }
1090
1101
 
1091
1102
  /**
1092
1103
  * The UTF-16 index at which each code point of `cps` starts, plus one final
@@ -1148,7 +1159,7 @@ const CONTAINING_CANDIDATE_SHARE = 4;
1148
1159
  function clusterResolver(body, offsets, candidates) {
1149
1160
  const cpCount = offsets.length - 1;
1150
1161
  if (candidates * CONTAINING_CANDIDATE_SHARE < cpCount) {
1151
- const segments = GRAPHEME_SEGMENTER.segment(body);
1162
+ const segments = graphemeSegmenter().segment(body);
1152
1163
  return (cp) => {
1153
1164
  // `offsets[cp]` is inside the string for every code-point index the walk
1154
1165
  // asks about, so the segment always exists.
@@ -1165,7 +1176,7 @@ function clusterResolver(body, offsets, candidates) {
1165
1176
  // cursor in step with it so no boundary needs searching for. The iterator is
1166
1177
  // abandoned wherever the last candidate leaves it, so a document whose
1167
1178
  // preserve budget is spent in its first line is never segmented past it.
1168
- const iterator = GRAPHEME_SEGMENTER.segment(body)[Symbol.iterator]();
1179
+ const iterator = graphemeSegmenter().segment(body)[Symbol.iterator]();
1169
1180
  let start = 0;
1170
1181
  let end = 0;
1171
1182
  return (cp) => {
@@ -188,7 +188,8 @@ export function cliMain(opts?: {
188
188
  * must be blocked, or null to let the pipeline continue. Hosts use these for
189
189
  * policy the package has no view of (a required workflow step, a project-local
190
190
  * rule); the package ships none.
191
- * @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string })
191
+ * @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string,
192
+ * permission_mode?: string })
192
193
  * => string | null | undefined} HostGate
193
194
  */
194
195
  /**
@@ -221,6 +222,7 @@ export type HostGate = (input: {
221
222
  tool_name: string | null;
222
223
  tool_input: any;
223
224
  session_id?: string;
225
+ permission_mode?: string;
224
226
  }) => string | null | undefined;
225
227
  declare const rehydrateRedacted: typeof import("agent-sanitizer/rehydrate").rehydrateRedacted;
226
228
  export {};