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 +12 -3
- package/package.json +1 -1
- package/src/output.mjs +108 -50
- package/types/output.d.mts +12 -1
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
1117
|
-
*
|
|
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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
"
|
|
1170
|
+
"text",
|
|
1131
1171
|
{
|
|
1132
|
-
required:
|
|
1133
|
-
|
|
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
|
-
"
|
|
1179
|
+
"image",
|
|
1138
1180
|
{
|
|
1139
|
-
required:
|
|
1140
|
-
optional:
|
|
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
|
-
"
|
|
1147
|
-
{
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
"
|
|
1199
|
+
"search_result",
|
|
1155
1200
|
{
|
|
1156
|
-
required:
|
|
1157
|
-
optional:
|
|
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
|
-
"
|
|
1162
|
-
{ required:
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
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;
|
package/types/output.d.mts
CHANGED
|
@@ -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";
|