agent-sanitizer 2.57.0 → 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/README.md +7 -2
- package/THREAT-MODEL.md +11 -1
- package/claude-hooks/lib/trace.mjs +29 -1
- package/claude-hooks/pretooluse-sanitize.mjs +14 -9
- package/claude-hooks/sanitize-output.mjs +4 -13
- package/claude-hooks/sanitize-user-prompt.mjs +3 -3
- package/claude-hooks/scan-invisible-chars.mjs +12 -5
- package/claude-hooks/scan-loaded-instructions.mjs +11 -4
- package/package.json +1 -1
- package/src/output.mjs +85 -1
- package/types/claude-hooks/lib/trace.d.mts +23 -2
- package/types/output.d.mts +3 -1
package/README.md
CHANGED
|
@@ -357,8 +357,13 @@ the extension bag's `trace` on
|
|
|
357
357
|
receives the same `TraceEvent` names the default emits, and it **replaces** the
|
|
358
358
|
default rather than running alongside it — the package channel goes silent, so
|
|
359
359
|
there is one announcement to detect, not two. It may throw freely: each hook
|
|
360
|
-
binds the sink it is given through `
|
|
361
|
-
|
|
360
|
+
binds the sink it is given through `hookTrace`, so an announcement can never be
|
|
361
|
+
the thing that breaks a hook.
|
|
362
|
+
|
|
363
|
+
That same binding charges whatever a host sink spends — a write to an unanswered
|
|
364
|
+
socket, a subprocess — to the slow-hook notice's host-extension window. A hook
|
|
365
|
+
past its budget then names the sink, instead of leaving the wait in the
|
|
366
|
+
unattributed remainder and the sink's in-process CPU billed to the sanitizer.
|
|
362
367
|
|
|
363
368
|
**A host's own cold-start marker can replace the derived one.** The hooks wait
|
|
364
369
|
out an in-flight dependency install by polling a marker file whose path they
|
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
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { appendFileSync } from "node:fs";
|
|
22
|
+
import { chargeHostExtensionSync } from "./hook-timing.mjs";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* The sink shape a hook emits through: the event name, its metadata fields, and
|
|
@@ -26,7 +27,7 @@ import { appendFileSync } from "node:fs";
|
|
|
26
27
|
* default emits, so it can remap them onto its own channel's vocabulary.
|
|
27
28
|
*
|
|
28
29
|
* A sink is NOT required to be total — throw freely. Every hook binds the one it
|
|
29
|
-
* was given through {@link
|
|
30
|
+
* was given through {@link hookTrace}, which is what upholds the channel's
|
|
30
31
|
* never-breaks-a-hook posture on host code that cannot promise it.
|
|
31
32
|
* @typedef {(event: string, fields?: Record<string, unknown>, level?: "info"|"debug") => void} TraceFn
|
|
32
33
|
*/
|
|
@@ -105,3 +106,30 @@ export function bestEffortTrace(sink) {
|
|
|
105
106
|
}
|
|
106
107
|
};
|
|
107
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The sink a hook emits through, given a host's or none: a HOST sink is made
|
|
112
|
+
* best-effort and charged to the slow-hook notice's host-extension window; the
|
|
113
|
+
* package's own {@link trace} is handed back untouched.
|
|
114
|
+
*
|
|
115
|
+
* A composer's sink may write over a socket or spawn a subprocess whose cost
|
|
116
|
+
* this process cannot see, so an uncharged one leaves its wait in the notice's
|
|
117
|
+
* unattributed remainder and its in-process CPU billed to the sanitizer. The
|
|
118
|
+
* default sink's file write IS the sanitizer's own work, so charging it would
|
|
119
|
+
* move a real per-call cost out of the figure that names it.
|
|
120
|
+
*
|
|
121
|
+
* Both properties are bound HERE, not at each hook, so a sixth caller can
|
|
122
|
+
* neither drop one nor nest them wrong — and the nesting is load-bearing twice
|
|
123
|
+
* over. The charge is booked in a `finally` inside the best-effort bracket, so
|
|
124
|
+
* a sink that spends its wait and THEN throws is measured before the throw is
|
|
125
|
+
* swallowed. And the exemption above reads the host's own sink, which a
|
|
126
|
+
* best-effort wrapper applied first would have replaced with a truthy one.
|
|
127
|
+
* @param {TraceFn} [sink] a host's sink; absent asks for the package channel
|
|
128
|
+
* @returns {TraceFn}
|
|
129
|
+
*/
|
|
130
|
+
export function hookTrace(sink) {
|
|
131
|
+
if (!sink) return trace;
|
|
132
|
+
return bestEffortTrace((event, fields, level) =>
|
|
133
|
+
chargeHostExtensionSync(() => sink(event, fields, level)),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -73,7 +73,7 @@ import {
|
|
|
73
73
|
LAYER2_PLACEHOLDER_RE,
|
|
74
74
|
} from "./lib/placeholder-grammar.mjs";
|
|
75
75
|
import { readSpan, spanPath } from "./lib/reveal.mjs";
|
|
76
|
-
import {
|
|
76
|
+
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
77
77
|
|
|
78
78
|
const HOOK_NAME = "pretooluse-sanitize";
|
|
79
79
|
|
|
@@ -474,11 +474,11 @@ function toolTargetDir(tool, toolInput) {
|
|
|
474
474
|
export async function buildPreToolUseResponse(
|
|
475
475
|
input,
|
|
476
476
|
rehydrate = defaultRehydrate,
|
|
477
|
-
sink
|
|
477
|
+
sink,
|
|
478
478
|
) {
|
|
479
|
-
// Every path into the announcement runs through here, so this is the one
|
|
480
|
-
// a host sink has to be
|
|
481
|
-
const emitTrace =
|
|
479
|
+
// Every path into the announcement runs through here, so this is the one
|
|
480
|
+
// place a host sink has to be bound (see hookTrace).
|
|
481
|
+
const emitTrace = hookTrace(sink);
|
|
482
482
|
const asks = [];
|
|
483
483
|
const contexts = [];
|
|
484
484
|
|
|
@@ -618,7 +618,11 @@ function assembleResponse({
|
|
|
618
618
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
619
619
|
*/
|
|
620
620
|
export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
621
|
-
|
|
621
|
+
// Forwarded UNRESOLVED: substituting the package sink for an absent one here
|
|
622
|
+
// would hand hookTrace a truthy sink, charging this package's own trace write
|
|
623
|
+
// to the host window — the misattribution the split exists to prevent. Only
|
|
624
|
+
// buildPreToolUseResponse's binding may pick the default.
|
|
625
|
+
const { gates = [], trace: sink } = opts;
|
|
622
626
|
// MERGED over the defaults, never substituted for them. A host that overrides
|
|
623
627
|
// one field would otherwise leave the rest undefined, and the miss lands in
|
|
624
628
|
// the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
|
|
@@ -653,7 +657,7 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
|
653
657
|
const denyReason = gate(input);
|
|
654
658
|
if (denyReason) return { decision: Decision.DENY, reason: denyReason };
|
|
655
659
|
}
|
|
656
|
-
const fields = await buildPreToolUseResponse(input, rehydrate,
|
|
660
|
+
const fields = await buildPreToolUseResponse(input, rehydrate, sink);
|
|
657
661
|
if (fields === null) return { decision: Decision.ALLOW };
|
|
658
662
|
/** @type {Record<string, unknown>} */
|
|
659
663
|
const verdict = {
|
|
@@ -897,7 +901,8 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
897
901
|
* @returns {Promise<void>}
|
|
898
902
|
*/
|
|
899
903
|
export async function cliMain(opts = {}) {
|
|
900
|
-
|
|
904
|
+
// Unresolved, for the reason judgePreToolUseSanitize states.
|
|
905
|
+
const { gates = [], trace: sink } = opts;
|
|
901
906
|
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
902
907
|
await runJudgeCli(
|
|
903
908
|
HOOK_NAME,
|
|
@@ -905,7 +910,7 @@ export async function cliMain(opts = {}) {
|
|
|
905
910
|
judgePreToolUseSanitize(event, undefined, {
|
|
906
911
|
messages,
|
|
907
912
|
gates,
|
|
908
|
-
trace:
|
|
913
|
+
trace: sink,
|
|
909
914
|
}),
|
|
910
915
|
{
|
|
911
916
|
// The caller's posture, WITHOUT the package: pass through with a warning
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
} from "./lib/hook-io.mjs";
|
|
48
48
|
import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
|
|
49
49
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
50
|
-
import {
|
|
50
|
+
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
51
51
|
import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
|
|
52
52
|
import { digestFlaggingEnabled, secretsEnabled } from "./lib/env-config.mjs";
|
|
53
53
|
import {
|
|
@@ -856,18 +856,9 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
856
856
|
* @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
|
|
857
857
|
*/
|
|
858
858
|
export async function evaluateToolOutput(input, ext = {}) {
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
|
|
862
|
-
// cannot see the cost of — while the package's own sink is not, since that
|
|
863
|
-
// file write is the sanitizer's own work.
|
|
864
|
-
const hostTrace = ext.trace;
|
|
865
|
-
const emitTrace = bestEffortTrace(
|
|
866
|
-
hostTrace
|
|
867
|
-
? (event, fields) =>
|
|
868
|
-
chargeHostExtensionSync(() => hostTrace(event, fields))
|
|
869
|
-
: trace,
|
|
870
|
-
);
|
|
859
|
+
// A host callback that throws must not be the thing that suppresses a tool
|
|
860
|
+
// output (see hookTrace).
|
|
861
|
+
const emitTrace = hookTrace(ext.trace);
|
|
871
862
|
/**
|
|
872
863
|
* @param {string} outcome noop | clean | flagged | modified
|
|
873
864
|
* @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
writeFaultOutcome,
|
|
33
33
|
} from "./lib/hook-fault.mjs";
|
|
34
34
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
35
|
-
import {
|
|
35
|
+
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
36
36
|
// classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
|
|
37
37
|
// come from the agent-sanitizer package. They are bound by a *caught* dynamic
|
|
38
38
|
// import, never a bare top-level `import … from "…"`: a static npm import
|
|
@@ -208,10 +208,10 @@ export async function main(read, write, opts = {}) {
|
|
|
208
208
|
const {
|
|
209
209
|
strip = stripAnsiFully,
|
|
210
210
|
overrides = USER_PROMPT_MESSAGES,
|
|
211
|
-
trace: sink
|
|
211
|
+
trace: sink,
|
|
212
212
|
env = process.env,
|
|
213
213
|
} = opts;
|
|
214
|
-
const emitTrace =
|
|
214
|
+
const emitTrace = hookTrace(sink);
|
|
215
215
|
// Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
|
|
216
216
|
// call site where a missing field would throw out of the catch and fail OPEN.
|
|
217
217
|
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
import { formatReport } from "./lib/invisible-report.mjs";
|
|
52
52
|
import { sweepStaleReveals } from "./lib/reveal.mjs";
|
|
53
53
|
import { sweepStaleConfirms } from "./lib/secret-drop-guard.mjs";
|
|
54
|
-
import {
|
|
54
|
+
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
55
55
|
import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
|
|
56
56
|
// Relative, not the `agent-sanitizer` specifier every other engine import uses:
|
|
57
57
|
// this is the scan's SCOPE, which is hook policy, and package.json's exports map
|
|
@@ -412,7 +412,14 @@ export async function cliMain(opts = {}) {
|
|
|
412
412
|
HookEvent.SESSION_START,
|
|
413
413
|
emitHookResponse,
|
|
414
414
|
undefined,
|
|
415
|
-
|
|
415
|
+
// All four windows, including the two this scan normally leaves empty: a
|
|
416
|
+
// measured 0 rules a window OUT, where an omitted one leaves the notice
|
|
417
|
+
// naming candidates it cannot separate.
|
|
418
|
+
{
|
|
419
|
+
cpuMs: timer.cpuMs(),
|
|
420
|
+
redactorMs: timer.redactorMs(),
|
|
421
|
+
hostMs: timer.hostMs(),
|
|
422
|
+
},
|
|
416
423
|
);
|
|
417
424
|
}
|
|
418
425
|
}
|
|
@@ -428,11 +435,11 @@ export async function cliMain(opts = {}) {
|
|
|
428
435
|
* }} opts see {@link cliMain}
|
|
429
436
|
* @returns {Promise<void>}
|
|
430
437
|
*/
|
|
431
|
-
async function runScanCli({ trace: sink
|
|
438
|
+
async function runScanCli({ trace: sink, scan: runScan, sessionId }) {
|
|
432
439
|
// Bound best-effort: the announcements below run BEFORE the auto-clean and
|
|
433
440
|
// the alert write, with no catch above them, so a throwing host sink would
|
|
434
|
-
// abort the scan silently (see
|
|
435
|
-
const emitTrace =
|
|
441
|
+
// abort the scan silently (see hookTrace).
|
|
442
|
+
const emitTrace = hookTrace(sink);
|
|
436
443
|
// Everything the PreToolUse gate must surface this session, written once at
|
|
437
444
|
// the end: an incomplete scan and an uncleanable file are independent reasons
|
|
438
445
|
// to arm the gate, and two separate writes would have the second clobber the
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
appendAlert,
|
|
38
38
|
recordInstructionsLoaded,
|
|
39
39
|
} from "./lib/invisible-alert.mjs";
|
|
40
|
-
import {
|
|
40
|
+
import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
41
41
|
import { reportSlowHook, startHookTimer } from "./lib/hook-timing.mjs";
|
|
42
42
|
import { formatReport } from "./lib/invisible-report.mjs";
|
|
43
43
|
import {
|
|
@@ -256,9 +256,9 @@ export { HOOK_NAME };
|
|
|
256
256
|
* passes its sink (see lib/trace.mjs)
|
|
257
257
|
* @returns {Promise<void>}
|
|
258
258
|
*/
|
|
259
|
-
export async function cliMain({ trace: sink
|
|
259
|
+
export async function cliMain({ trace: sink } = {}) {
|
|
260
260
|
const timer = startHookTimer();
|
|
261
|
-
const emitTrace =
|
|
261
|
+
const emitTrace = hookTrace(sink);
|
|
262
262
|
/** @type {string | undefined} */
|
|
263
263
|
let sessionId;
|
|
264
264
|
try {
|
|
@@ -321,7 +321,14 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
321
321
|
HookEvent.INSTRUCTIONS_LOADED,
|
|
322
322
|
emitHookResponse,
|
|
323
323
|
undefined,
|
|
324
|
-
|
|
324
|
+
// All four windows, including the two this scan normally leaves empty: a
|
|
325
|
+
// measured 0 rules a window OUT, where an omitted one leaves the notice
|
|
326
|
+
// naming candidates it cannot separate.
|
|
327
|
+
{
|
|
328
|
+
cpuMs: timer.cpuMs(),
|
|
329
|
+
redactorMs: timer.redactorMs(),
|
|
330
|
+
hostMs: timer.hostMs(),
|
|
331
|
+
},
|
|
325
332
|
);
|
|
326
333
|
}
|
|
327
334
|
}
|
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
|
|
@@ -33,13 +33,34 @@ export function trace(event: string, fields?: Record<string, unknown>, level?: "
|
|
|
33
33
|
* @returns {TraceFn}
|
|
34
34
|
*/
|
|
35
35
|
export function bestEffortTrace(sink: TraceFn): TraceFn;
|
|
36
|
+
/**
|
|
37
|
+
* The sink a hook emits through, given a host's or none: a HOST sink is made
|
|
38
|
+
* best-effort and charged to the slow-hook notice's host-extension window; the
|
|
39
|
+
* package's own {@link trace} is handed back untouched.
|
|
40
|
+
*
|
|
41
|
+
* A composer's sink may write over a socket or spawn a subprocess whose cost
|
|
42
|
+
* this process cannot see, so an uncharged one leaves its wait in the notice's
|
|
43
|
+
* unattributed remainder and its in-process CPU billed to the sanitizer. The
|
|
44
|
+
* default sink's file write IS the sanitizer's own work, so charging it would
|
|
45
|
+
* move a real per-call cost out of the figure that names it.
|
|
46
|
+
*
|
|
47
|
+
* Both properties are bound HERE, not at each hook, so a sixth caller can
|
|
48
|
+
* neither drop one nor nest them wrong — and the nesting is load-bearing twice
|
|
49
|
+
* over. The charge is booked in a `finally` inside the best-effort bracket, so
|
|
50
|
+
* a sink that spends its wait and THEN throws is measured before the throw is
|
|
51
|
+
* swallowed. And the exemption above reads the host's own sink, which a
|
|
52
|
+
* best-effort wrapper applied first would have replaced with a truthy one.
|
|
53
|
+
* @param {TraceFn} [sink] a host's sink; absent asks for the package channel
|
|
54
|
+
* @returns {TraceFn}
|
|
55
|
+
*/
|
|
56
|
+
export function hookTrace(sink?: TraceFn): TraceFn;
|
|
36
57
|
/**
|
|
37
58
|
* The sink shape a hook emits through: the event name, its metadata fields, and
|
|
38
59
|
* the level. A host implementation receives the same {@link TraceEvent} names the
|
|
39
60
|
* default emits, so it can remap them onto its own channel's vocabulary.
|
|
40
61
|
*
|
|
41
62
|
* A sink is NOT required to be total — throw freely. Every hook binds the one it
|
|
42
|
-
* was given through {@link
|
|
63
|
+
* was given through {@link hookTrace}, which is what upholds the channel's
|
|
43
64
|
* never-breaks-a-hook posture on host code that cannot promise it.
|
|
44
65
|
* @typedef {(event: string, fields?: Record<string, unknown>, level?: "info"|"debug") => void} TraceFn
|
|
45
66
|
*/
|
|
@@ -55,7 +76,7 @@ export const TraceEvent: Readonly<{
|
|
|
55
76
|
* default emits, so it can remap them onto its own channel's vocabulary.
|
|
56
77
|
*
|
|
57
78
|
* A sink is NOT required to be total — throw freely. Every hook binds the one it
|
|
58
|
-
* was given through {@link
|
|
79
|
+
* was given through {@link hookTrace}, which is what upholds the channel's
|
|
59
80
|
* never-breaks-a-hook posture on host code that cannot promise it.
|
|
60
81
|
*/
|
|
61
82
|
export type TraceFn = (event: string, fields?: Record<string, unknown>, level?: "info" | "debug") => void;
|
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
|