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
|
-
- **
|
|
673
|
-
|
|
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
|
|
599
|
-
// gate keyed on the session (a once-per-session checkpoint)
|
|
600
|
-
// sessions apart without
|
|
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.
|
|
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
|
-
|
|
1088
|
-
|
|
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 =
|
|
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 =
|
|
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 {};
|