agent-sanitizer 2.57.2 → 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
@@ -500,12 +500,21 @@ suppression (`suppressToolOutput`) replaces string leaves in place so the
500
500
  placeholder still matches the tool's shape, with one exception it must make: an
501
501
  Anthropic **content block** is collapsed whole to `{ type: "text", text: … }`
502
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
503
+ under it — `citations`, `source`, `cache_control`) produces a block the API
504
504
  rejects with a 400, and the invalid block then replays on every later turn, so
505
505
  no retry clears it. A block is recognised only when its own keys match that
506
- tag's schema exactly, so an ordinary object that merely carries a `type` field
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 —
507
509
  keeps the leaf-wise walk; only the enum-valued tag is preserved, never a string
508
- from the block itself. In the Claude
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
509
518
  Code hooks the entire secret layer is **opt-in**: `secretsEnabled()`
510
519
  (`claude-hooks/lib/env-config.mjs`) reads `AGENT_SANITIZER_SECRETS_ENABLED=1`,
511
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.2",
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
@@ -1103,7 +1103,10 @@ export function composeContext(
1103
1103
  * self-referential value must NOT blow the stack here — that would re-open the
1104
1104
  * very hole suppression exists to close. Past {@link MAX_DEPTH} or on a cycle it
1105
1105
  * substitutes `message` for the offending subtree (already the suppression
1106
- * 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.
1107
1110
  * @param {any} value
1108
1111
  * @param {string} message
1109
1112
  * @returns {any}
@@ -1113,77 +1116,135 @@ export function suppressToolOutput(value, message) {
1113
1116
  }
1114
1117
 
1115
1118
  /**
1116
- * Own-key schema of every Anthropic content block the suppressor recognises,
1117
- * from the Messages API request shapes
1118
- * (https://docs.claude.com/en/api/messages). A block is recognised only when
1119
- * its own keys match its tag's schema exactly — every `required` key present,
1120
- * no key outside `required` ∪ `optional` ∪ `type`. An unrecognised object is
1121
- * walked as ordinary data, so a schema entry that is wrong or missing costs a
1122
- * false NEGATIVE (the leaf-wise walk) rather than collapsing a legitimate
1123
- * object that merely carries a `type` field.
1124
- * @type {Map<string, { required: string[], optional: string[] }>}
1119
+ * @typedef {(v: any) => boolean} FieldShape a content-block field's value test
1120
+ * @typedef {{ required: Record<string, FieldShape>, optional: Record<string, FieldShape> }} BlockSchema
1125
1121
  */
1126
- const CONTENT_BLOCK_SCHEMAS = new Map([
1127
- ["text", { required: ["text"], optional: ["citations", "cache_control"] }],
1128
- ["image", { required: ["source"], optional: ["cache_control"] }],
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 = [
1129
1169
  [
1130
- "document",
1170
+ "text",
1131
1171
  {
1132
- required: ["source"],
1133
- optional: ["title", "context", "citations", "cache_control"],
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 },
1134
1176
  },
1135
1177
  ],
1136
1178
  [
1137
- "search_result",
1179
+ "image",
1138
1180
  {
1139
- required: ["source", "title", "content"],
1140
- optional: ["citations", "cache_control"],
1181
+ required: { source: isRecord },
1182
+ optional: { cache_control: isNullableRecord },
1141
1183
  },
1142
1184
  ],
1143
- ["thinking", { required: ["thinking", "signature"], optional: [] }],
1144
- ["redacted_thinking", { required: ["data"], optional: [] }],
1145
1185
  [
1146
- "tool_use",
1147
- { required: ["id", "name", "input"], optional: ["cache_control"] },
1148
- ],
1149
- [
1150
- "server_tool_use",
1151
- { required: ["id", "name", "input"], optional: ["cache_control"] },
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
+ },
1152
1197
  ],
1153
1198
  [
1154
- "tool_result",
1199
+ "search_result",
1155
1200
  {
1156
- required: ["tool_use_id"],
1157
- optional: ["content", "is_error", "cache_control"],
1201
+ required: { source: isString, title: isString, content: isArray },
1202
+ optional: {
1203
+ citations: isNullableRecord,
1204
+ cache_control: isNullableRecord,
1205
+ },
1158
1206
  },
1159
1207
  ],
1160
1208
  [
1161
- "web_search_tool_result",
1162
- { required: ["tool_use_id", "content"], optional: ["cache_control"] },
1209
+ "thinking",
1210
+ { required: { thinking: isString, signature: isString }, optional: {} },
1163
1211
  ],
1164
- ]);
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);
1165
1217
 
1166
1218
  /**
1167
1219
  * Whether `value` (already known to be a walkable container) is an Anthropic
1168
1220
  * content block — an object whose `type` tag names a known block shape AND
1169
- * whose own keys match that shape exactly.
1221
+ * whose own keys and values match that shape exactly.
1170
1222
  * @param {any} value
1171
1223
  * @returns {boolean}
1172
1224
  */
1173
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.
1174
1228
  if (Array.isArray(value)) return false;
1175
- const keys = Object.keys(value);
1176
- if (!keys.includes("type")) return false;
1177
1229
  const schema = CONTENT_BLOCK_SCHEMAS.get(value.type);
1178
- if (schema === undefined) return false;
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
+ };
1179
1245
  return (
1180
- schema.required.every((key) => keys.includes(key)) &&
1181
- keys.every(
1182
- (key) =>
1183
- key === "type" ||
1184
- schema.required.includes(key) ||
1185
- schema.optional.includes(key),
1186
- )
1246
+ Object.keys(schema.required).every((key) => Object.hasOwn(value, key)) &&
1247
+ Object.keys(value).every((key) => key === "type" || isValidField(key))
1187
1248
  );
1188
1249
  }
1189
1250
 
@@ -1205,12 +1266,9 @@ function suppressAt(value, message, depth, seen, memo) {
1205
1266
  // Same opaque-leaf rule as sanitizeValueAt: only arrays and plain objects are
1206
1267
  // walked; an exotic object would be corrupted to an empty clone.
1207
1268
  if (!isWalkableContainer(value)) return value;
1208
- // A content block's `type` tag tells the API how to parse the block, and the
1209
- // tagged unions under it (`citations[]`, `source`, `cache_control`) are
1210
- // validated the same way so walking a block's keys yields one the API
1211
- // rejects with a 400, permanently: it stays in the transcript and replays on
1212
- // every later turn. Collapse to the one block shape `message` is legal in.
1213
- // Only the enum-valued tag survives, never a string from the block itself.
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.
1214
1272
  if (isContentBlock(value)) return { type: "text", text: message };
1215
1273
  const cached = memo.get(value, depth);
1216
1274
  if (cached !== undefined) return cached;
@@ -184,7 +184,10 @@ export function composeContext(modified: boolean, warnings: string[], { injectio
184
184
  * self-referential value must NOT blow the stack here — that would re-open the
185
185
  * very hole suppression exists to close. Past {@link MAX_DEPTH} or on a cycle it
186
186
  * substitutes `message` for the offending subtree (already the suppression
187
- * 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.
188
191
  * @param {any} value
189
192
  * @param {string} message
190
193
  * @returns {any}
@@ -287,5 +290,13 @@ export type SanitizeTextOptions = {
287
290
  export type Deadline = {
288
291
  remainingMs: () => number;
289
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
+ };
290
301
  import { needsMarkdownPipeline } from "./gates.mjs";
291
302
  export { describeExfil, describeRemoved, describeWarned } from "./warnings.mjs";