agent-sanitizer 2.57.1 → 2.57.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
@@ -495,7 +495,26 @@ RECONSTITUTES during stripping is judged as the sequence it becomes.
495
495
  (a harness that gets a shape-mismatched value silently shows the raw output).
496
496
  Layer 4 (secret redaction) is an **injected** redactor and is the one
497
497
  fail-closed path: a redactor that throws makes the pipeline rethrow, so the
498
- caller suppresses the output rather than emit an unvetted value. In the Claude
498
+ caller suppresses the output rather than emit an unvetted value. That
499
+ suppression (`suppressToolOutput`) replaces string leaves in place so the
500
+ placeholder still matches the tool's shape, with one exception it must make: an
501
+ Anthropic **content block** is collapsed whole to `{ type: "text", text: … }`
502
+ rather than walked, because rewriting the `type` tag (or a tagged union nested
503
+ under it — `citations`, `source`, `cache_control`) produces a block the API
504
+ rejects with a 400, and the invalid block then replays on every later turn, so
505
+ no retry clears it. A block is recognised only when its own keys match that
506
+ tag's schema exactly **and** each value has that field's shape, so an ordinary
507
+ object that merely carries a `type` field — or an `{ type: "image", source:
508
+ "https://…" }` record whose `source` is a URL rather than the block's object —
509
+ keeps the leaf-wise walk; only the enum-valued tag is preserved, never a string
510
+ from the block itself. Two residual cases are accepted rather than papered over.
511
+ A tag that PAIRS a block with another block (`tool_use` ↔ `tool_result`, and
512
+ their server-tool twins) is deliberately not recognised: collapsing one orphans
513
+ its partner, which is the same permanent rejection, so those keep the walk and
514
+ #398 stands for them. And the depth/cycle truncation still substitutes the bare
515
+ sentinel string for whatever subtree it cuts, block position or not — the walk
516
+ cannot tell a content position from an ordinary array from inside, and guessing
517
+ would rewrite ordinary arrays of strings into blocks. In the Claude
499
518
  Code hooks the entire secret layer is **opt-in**: `secretsEnabled()`
500
519
  (`claude-hooks/lib/env-config.mjs`) reads `AGENT_SANITIZER_SECRETS_ENABLED=1`,
501
520
  and every secret-layer guarantee below — Layer-4 redaction, rehydration, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.57.1",
3
+ "version": "2.57.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/output.mjs CHANGED
@@ -1094,14 +1094,19 @@ export function composeContext(
1094
1094
  /**
1095
1095
  * Replace every string leaf of `value` with `message`, preserving shape so a
1096
1096
  * fail-closed placeholder matches the tool's output schema. Non-string leaves
1097
- * pass through.
1097
+ * pass through. An Anthropic content block is the one exception: it is
1098
+ * collapsed whole to `{ type: "text", text: message }`, because rewriting its
1099
+ * `type` tag produces a block the API rejects (see {@link isContentBlock}).
1098
1100
  *
1099
1101
  * Shares {@link sanitizeValue}'s depth/cycle guard for the same reason: this
1100
1102
  * runs on the fail-closed path (an already-suspect output), so a 200k-deep or
1101
1103
  * self-referential value must NOT blow the stack here — that would re-open the
1102
1104
  * very hole suppression exists to close. Past {@link MAX_DEPTH} or on a cycle it
1103
1105
  * substitutes `message` for the offending subtree (already the suppression
1104
- * sentinel, so the placeholder is consistent with the rest of the output).
1106
+ * sentinel, so the placeholder is consistent with the rest of the output). A
1107
+ * recognised block collapses BEFORE either guard and recurses no further, so it
1108
+ * is subject to neither; a truncated subtree that is not itself a block is
1109
+ * still replaced by the bare string, block position or not.
1105
1110
  * @param {any} value
1106
1111
  * @param {string} message
1107
1112
  * @returns {any}
@@ -1110,6 +1115,139 @@ export function suppressToolOutput(value, message) {
1110
1115
  return suppressAt(value, message, 0, new WeakSet(), depthMemo());
1111
1116
  }
1112
1117
 
1118
+ /**
1119
+ * @typedef {(v: any) => boolean} FieldShape a content-block field's value test
1120
+ * @typedef {{ required: Record<string, FieldShape>, optional: Record<string, FieldShape> }} BlockSchema
1121
+ */
1122
+
1123
+ /** @type {FieldShape} */
1124
+ const isString = (v) => typeof v === "string";
1125
+ /** @type {FieldShape} */
1126
+ const isRecord = (v) =>
1127
+ v !== null && typeof v === "object" && !Array.isArray(v);
1128
+ /** @type {FieldShape} */
1129
+ const isNullableString = (v) => v === null || isString(v);
1130
+ /** @type {FieldShape} */
1131
+ const isNullableArray = (v) => v === null || Array.isArray(v);
1132
+ // The API marks every optional object-valued block field nullable, and an
1133
+ // explicit null must not push the block back onto the walk that invalidates it.
1134
+ /** @type {FieldShape} */
1135
+ const isNullableRecord = (v) => v === null || isRecord(v);
1136
+ /** @type {FieldShape} */
1137
+ const isArray = (v) => Array.isArray(v);
1138
+
1139
+ /**
1140
+ * Schema of every Anthropic content block the suppressor recognises, from the
1141
+ * Messages API block shapes (https://docs.claude.com/en/api/messages). A block
1142
+ * is recognised only when its own keys match its tag's schema exactly — every
1143
+ * `required` key present, no key outside `required` ∪ `optional` ∪ `type` —
1144
+ * AND every present key's VALUE satisfies its predicate. Gating on the value's
1145
+ * shape and not the key name alone is what keeps an ordinary record like
1146
+ * `{ type: "image", source: "https://…/x.png" }` out: a real image block's
1147
+ * `source` is an object. An unrecognised object is walked as ordinary data.
1148
+ *
1149
+ * Both directions cost something, and they are not symmetric in the way the
1150
+ * rest of this module's precision rule assumes. Too LOOSE mangles an object
1151
+ * that was never a block. Too STRICT is fail-safe only for those same
1152
+ * non-blocks: for a REAL block it sends the walk over the `type` tag, which is
1153
+ * the permanently-rejected block this collapse exists to prevent. So a
1154
+ * predicate must admit every value the API admits — hence the nullable
1155
+ * variants below, since every optional object-valued field is `object | null`.
1156
+ *
1157
+ * A block whose tag PAIRS it with another block (`tool_use` ↔ `tool_result`,
1158
+ * and their server-tool twins) is deliberately absent: collapsing one to a text
1159
+ * block orphans its partner, which the API rejects exactly as permanently as
1160
+ * the rewritten tag this collapse exists to prevent. They keep the walk.
1161
+ *
1162
+ * Held as an entry list rather than annotated on the `new Map(...)` below
1163
+ * because only the element-wise annotation typechecks: annotating the map lets
1164
+ * tsc union the entry literals first, and that union's `source?: undefined`
1165
+ * members fail `BlockSchema`'s index signature.
1166
+ * @type {[string, BlockSchema][]}
1167
+ */
1168
+ const CONTENT_BLOCK_SCHEMA_ENTRIES = [
1169
+ [
1170
+ "text",
1171
+ {
1172
+ required: { text: isString },
1173
+ // A response's text block carries `citations` as an array (or null); a
1174
+ // request's carries none.
1175
+ optional: { citations: isNullableArray, cache_control: isNullableRecord },
1176
+ },
1177
+ ],
1178
+ [
1179
+ "image",
1180
+ {
1181
+ required: { source: isRecord },
1182
+ optional: { cache_control: isNullableRecord },
1183
+ },
1184
+ ],
1185
+ [
1186
+ "document",
1187
+ {
1188
+ required: { source: isRecord },
1189
+ // A document's `citations` is the `{ enabled }` toggle, not a list.
1190
+ optional: {
1191
+ title: isNullableString,
1192
+ context: isNullableString,
1193
+ citations: isNullableRecord,
1194
+ cache_control: isNullableRecord,
1195
+ },
1196
+ },
1197
+ ],
1198
+ [
1199
+ "search_result",
1200
+ {
1201
+ required: { source: isString, title: isString, content: isArray },
1202
+ optional: {
1203
+ citations: isNullableRecord,
1204
+ cache_control: isNullableRecord,
1205
+ },
1206
+ },
1207
+ ],
1208
+ [
1209
+ "thinking",
1210
+ { required: { thinking: isString, signature: isString }, optional: {} },
1211
+ ],
1212
+ ["redacted_thinking", { required: { data: isString }, optional: {} }],
1213
+ ];
1214
+
1215
+ /** Tag → schema, keyed for {@link isContentBlock}'s lookup. */
1216
+ const CONTENT_BLOCK_SCHEMAS = new Map(CONTENT_BLOCK_SCHEMA_ENTRIES);
1217
+
1218
+ /**
1219
+ * Whether `value` (already known to be a walkable container) is an Anthropic
1220
+ * content block — an object whose `type` tag names a known block shape AND
1221
+ * whose own keys and values match that shape exactly.
1222
+ * @param {any} value
1223
+ * @returns {boolean}
1224
+ */
1225
+ function isContentBlock(value) {
1226
+ // An array with own `type`/`text` properties still occupies an array
1227
+ // position, where a block may not be substituted for the array itself.
1228
+ if (Array.isArray(value)) return false;
1229
+ const schema = CONTENT_BLOCK_SCHEMAS.get(value.type);
1230
+ // The own-key check is what blocks a polluted `Object.prototype.type`: the
1231
+ // `value.type` read above resolves through the prototype, so without it
1232
+ // `{ text: "leak" }` would tag itself a text block and be collapsed, dropping
1233
+ // a legitimate field on the fail-closed path.
1234
+ if (schema === undefined || !Object.hasOwn(value, "type")) return false;
1235
+ // Object.hasOwn, not a bare index: a bare lookup resolves inherited
1236
+ // Object.prototype members ("toString", "constructor") to real functions,
1237
+ // letting a key outside the schema pass as if it had a predicate.
1238
+ const isValidField = (/** @type {string} */ key) => {
1239
+ if (Object.hasOwn(schema.required, key))
1240
+ return schema.required[key](value[key]);
1241
+ if (Object.hasOwn(schema.optional, key))
1242
+ return schema.optional[key](value[key]);
1243
+ return false;
1244
+ };
1245
+ return (
1246
+ Object.keys(schema.required).every((key) => Object.hasOwn(value, key)) &&
1247
+ Object.keys(value).every((key) => key === "type" || isValidField(key))
1248
+ );
1249
+ }
1250
+
1113
1251
  /**
1114
1252
  * Recursion core for {@link suppressToolOutput}; see {@link sanitizeValueAt} for
1115
1253
  * the depth/`seen` bookkeeping rationale.
@@ -1128,6 +1266,10 @@ function suppressAt(value, message, depth, seen, memo) {
1128
1266
  // Same opaque-leaf rule as sanitizeValueAt: only arrays and plain objects are
1129
1267
  // walked; an exotic object would be corrupted to an empty clone.
1130
1268
  if (!isWalkableContainer(value)) return value;
1269
+ // Walking a block's keys rewrites its `type` tag and the tagged unions under
1270
+ // it, yielding a block the API rejects with a 400 that then replays on every
1271
+ // later turn. Collapse to the one block shape `message` is legal in.
1272
+ if (isContentBlock(value)) return { type: "text", text: message };
1131
1273
  const cached = memo.get(value, depth);
1132
1274
  if (cached !== undefined) return cached;
1133
1275
  // Placeholders are not cached at all: they are O(1) to recompute, and the
@@ -175,14 +175,19 @@ export function composeContext(modified: boolean, warnings: string[], { injectio
175
175
  /**
176
176
  * Replace every string leaf of `value` with `message`, preserving shape so a
177
177
  * fail-closed placeholder matches the tool's output schema. Non-string leaves
178
- * pass through.
178
+ * pass through. An Anthropic content block is the one exception: it is
179
+ * collapsed whole to `{ type: "text", text: message }`, because rewriting its
180
+ * `type` tag produces a block the API rejects (see {@link isContentBlock}).
179
181
  *
180
182
  * Shares {@link sanitizeValue}'s depth/cycle guard for the same reason: this
181
183
  * runs on the fail-closed path (an already-suspect output), so a 200k-deep or
182
184
  * self-referential value must NOT blow the stack here — that would re-open the
183
185
  * very hole suppression exists to close. Past {@link MAX_DEPTH} or on a cycle it
184
186
  * substitutes `message` for the offending subtree (already the suppression
185
- * sentinel, so the placeholder is consistent with the rest of the output).
187
+ * sentinel, so the placeholder is consistent with the rest of the output). A
188
+ * recognised block collapses BEFORE either guard and recurses no further, so it
189
+ * is subject to neither; a truncated subtree that is not itself a block is
190
+ * still replaced by the bare string, block position or not.
186
191
  * @param {any} value
187
192
  * @param {string} message
188
193
  * @returns {any}
@@ -285,5 +290,13 @@ export type SanitizeTextOptions = {
285
290
  export type Deadline = {
286
291
  remainingMs: () => number;
287
292
  };
293
+ /**
294
+ * a content-block field's value test
295
+ */
296
+ export type FieldShape = (v: any) => boolean;
297
+ export type BlockSchema = {
298
+ required: Record<string, FieldShape>;
299
+ optional: Record<string, FieldShape>;
300
+ };
288
301
  import { needsMarkdownPipeline } from "./gates.mjs";
289
302
  export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";