agent-sanitizer 2.57.1 → 2.57.2
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 +11 -1
- package/package.json +1 -1
- package/src/output.mjs +85 -1
- package/types/output.d.mts +3 -1
package/THREAT-MODEL.md
CHANGED
|
@@ -495,7 +495,17 @@ 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, so an ordinary object that merely carries a `type` field
|
|
507
|
+
keeps the leaf-wise walk; only the enum-valued tag is preserved, never a string
|
|
508
|
+
from the block itself. In the Claude
|
|
499
509
|
Code hooks the entire secret layer is **opt-in**: `secretsEnabled()`
|
|
500
510
|
(`claude-hooks/lib/env-config.mjs`) reads `AGENT_SANITIZER_SECRETS_ENABLED=1`,
|
|
501
511
|
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.2",
|
|
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,7 +1094,9 @@ 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
|
|
@@ -1110,6 +1112,81 @@ export function suppressToolOutput(value, message) {
|
|
|
1110
1112
|
return suppressAt(value, message, 0, new WeakSet(), depthMemo());
|
|
1111
1113
|
}
|
|
1112
1114
|
|
|
1115
|
+
/**
|
|
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[] }>}
|
|
1125
|
+
*/
|
|
1126
|
+
const CONTENT_BLOCK_SCHEMAS = new Map([
|
|
1127
|
+
["text", { required: ["text"], optional: ["citations", "cache_control"] }],
|
|
1128
|
+
["image", { required: ["source"], optional: ["cache_control"] }],
|
|
1129
|
+
[
|
|
1130
|
+
"document",
|
|
1131
|
+
{
|
|
1132
|
+
required: ["source"],
|
|
1133
|
+
optional: ["title", "context", "citations", "cache_control"],
|
|
1134
|
+
},
|
|
1135
|
+
],
|
|
1136
|
+
[
|
|
1137
|
+
"search_result",
|
|
1138
|
+
{
|
|
1139
|
+
required: ["source", "title", "content"],
|
|
1140
|
+
optional: ["citations", "cache_control"],
|
|
1141
|
+
},
|
|
1142
|
+
],
|
|
1143
|
+
["thinking", { required: ["thinking", "signature"], optional: [] }],
|
|
1144
|
+
["redacted_thinking", { required: ["data"], optional: [] }],
|
|
1145
|
+
[
|
|
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"] },
|
|
1152
|
+
],
|
|
1153
|
+
[
|
|
1154
|
+
"tool_result",
|
|
1155
|
+
{
|
|
1156
|
+
required: ["tool_use_id"],
|
|
1157
|
+
optional: ["content", "is_error", "cache_control"],
|
|
1158
|
+
},
|
|
1159
|
+
],
|
|
1160
|
+
[
|
|
1161
|
+
"web_search_tool_result",
|
|
1162
|
+
{ required: ["tool_use_id", "content"], optional: ["cache_control"] },
|
|
1163
|
+
],
|
|
1164
|
+
]);
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Whether `value` (already known to be a walkable container) is an Anthropic
|
|
1168
|
+
* content block — an object whose `type` tag names a known block shape AND
|
|
1169
|
+
* whose own keys match that shape exactly.
|
|
1170
|
+
* @param {any} value
|
|
1171
|
+
* @returns {boolean}
|
|
1172
|
+
*/
|
|
1173
|
+
function isContentBlock(value) {
|
|
1174
|
+
if (Array.isArray(value)) return false;
|
|
1175
|
+
const keys = Object.keys(value);
|
|
1176
|
+
if (!keys.includes("type")) return false;
|
|
1177
|
+
const schema = CONTENT_BLOCK_SCHEMAS.get(value.type);
|
|
1178
|
+
if (schema === undefined) return false;
|
|
1179
|
+
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
|
+
)
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1113
1190
|
/**
|
|
1114
1191
|
* Recursion core for {@link suppressToolOutput}; see {@link sanitizeValueAt} for
|
|
1115
1192
|
* the depth/`seen` bookkeeping rationale.
|
|
@@ -1128,6 +1205,13 @@ function suppressAt(value, message, depth, seen, memo) {
|
|
|
1128
1205
|
// Same opaque-leaf rule as sanitizeValueAt: only arrays and plain objects are
|
|
1129
1206
|
// walked; an exotic object would be corrupted to an empty clone.
|
|
1130
1207
|
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.
|
|
1214
|
+
if (isContentBlock(value)) return { type: "text", text: message };
|
|
1131
1215
|
const cached = memo.get(value, depth);
|
|
1132
1216
|
if (cached !== undefined) return cached;
|
|
1133
1217
|
// Placeholders are not cached at all: they are O(1) to recompute, and the
|
package/types/output.d.mts
CHANGED
|
@@ -175,7 +175,9 @@ 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
|