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 +20 -1
- package/package.json +1 -1
- package/src/output.mjs +144 -2
- package/types/output.d.mts +15 -2
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.
|
|
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.
|
|
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
|
package/types/output.d.mts
CHANGED
|
@@ -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";
|