/** * Host-supplied extensions to this hook, threaded from {@link cliMain} down to * each string leaf. Every field is optional and the bag defaults to `{}`, so a * composer that supplies none gets exactly the behavior of this module alone — * which is what lets the extension points ship without changing any shipped * verdict. The callbacks own all policy: this module decides only WHERE they run, * never WHETHER their result is applied. * * A callback that throws is not caught here. That is deliberate: the throw lands * in the CLI's fail-closed catch and the tool output is suppressed, so a broken * extension cannot degrade into showing unvetted output. * * These are NOT the Layer-5 injection-filter seam and do not inherit its * delete-only, closed-enum restriction. (Its identifier is deliberately unspoken * here: the plugin-bundle suite pins Layer 5 absent by asserting the name appears * nowhere in this file, and that absolute check is worth more than the precision * of one comment.) That restriction exists because such a * filter is a MODEL, so its output is attacker-reachable and must not be able to * inject text into the model-facing context. These callbacks are code the * composer wrote and linked at build time — the same trust level as the injected * redactor — so free-text warnings and arbitrary rewrites are theirs to own. * * @typedef {object} SanitizeExtensions * @property {(cleaned: string, ctx: { toolName: string, webIngress: boolean, deadline: { remainingMs: () => number } }) => Promise<{ cleaned?: string, warning?: string } | null | undefined> | { cleaned?: string, warning?: string } | null | undefined} [postText] * Runs once per string leaf, AFTER Layers 1-4. Returning `cleaned` replaces the * model-facing text; `warning` joins this leaf's warnings. * @property {(raw: string) => string | undefined} [redactNote] * Given the pre-redaction text of a leaf that tripped Layer 4, returns a note * appended to that leaf's "API keys/secrets redacted: …" warning. * @property {(record: { tool: string | null, session_id?: string, modified: boolean, output: unknown, context?: string }) => Promise | void} [audit] * Awaited once per judged event that carried a tool response, with the output * the model will actually see. `session_id` is the harness's session identity, * lifted from the event's `meta` — an audit trail that cannot say WHICH session * produced a record cannot be read back per-session, and the tool fields alone * do not carry it. Absent when the payload omitted it. * @property {import("./lib/trace.mjs").TraceFn} [trace] * Where this hook announces engagement. A host that already runs a trace * channel under its own environment variables passes its sink here, so the * announcement lands where its detector reads instead of on this package's * channel. Defaults to lib/trace.mjs's `trace`. * @property {string} [remedy] * What a reader should run when the sanitizer's own bindings are what is * missing. This hook's host channel is `ext`, where the other two gates use a * frozen message table; either way it is one channel per gate, so a host * cannot supply its wording somewhere the fail-closed context never reads. */ /** * Run Layers 1-4 over a single text blob, delegated to the package's output seam * (sanitizeTextSeam) bound here to this hook's per-tool policy: which tools get * the HTML rewrite (Layer 2) and the exfil-URL scan (Layer 3), the injected * secret redactor (Layer 4), and the display-only-SGR carve-out. `reveal` carries * the seam's pre-Layer-2 text when the HTML splice removed anything, for the * orchestrator to persist; `splices` is its per-placeholder twin (each * `original` already vetted by the seam's exit redaction, withheld entries * dropped there), for the orchestrator's per-key span persistence. * @param {string} text * @param {string} toolName gates the SGR carve-out and the untrusted-ingress passes * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across * all leaves of one hook run; a direct caller gets a fresh full budget * @param {SanitizeExtensions} [ext] * @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }>} */ export function sanitizeText(text: string, toolName: string, deadline?: { remainingMs: () => number; }, ext?: SanitizeExtensions): Promise<{ cleaned: string; warnings: string[]; notes: string[]; modified: boolean; sgrNote: boolean; reveal?: string; splices?: Array<{ placeholder: string; original: string; }>; }>; /** * Sanitize every string leaf of a tool-output value, preserving its shape. * Built-in tools return structured objects (Bash: `{stdout, stderr, interrupted, * isImage}`), and the harness ignores an `updatedToolOutput` whose shape does not * match the tool's schema — showing the raw output instead. So a single flat * string handed back for an object-shaped tool would leak the unsanitized output; * rewriting leaves in place keeps the shape intact. Object KEYS are sanitized * too (a connector can hide a secret in a field name); non-string leaves * (booleans, numbers, null) pass through untouched, and `warnings` accumulates * across leaves. * `sgrNote` is the OR across leaves: true when some leaf came back note-only. * `reveals` accumulates each leaf's pre-Layer-2 text (when the HTML splice * removed something) for the orchestrator to persist, and `notes` the leaves' * NOTE-severity findings — same mutated-accumulator shape as `warnings`. * `splices` accumulates each leaf's Layer-2 placeholder→original pairs (the * per-key twin of `reveals`, already vetted by the seam) for the orchestrator's * span persistence — same mutated-accumulator shape again. * @param {any} value * @param {string} toolName * @param {string[]} warnings * @param {string[]} [reveals] * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across * every leaf of this value (created once by the top-level caller) * @param {SanitizeExtensions} [ext] * @param {string[]} [notes] appended last so an existing caller's positional * arguments keep their meaning * @param {Array<{ placeholder: string, original: string }>} [splices] appended * after `notes` for the same positional-compatibility reason * @param {string} [path] dotted location of `value` within the tool output, * used only to name a key collision's location in its warning * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>} */ export function sanitizeValue(value: any, toolName: string, warnings: string[], reveals?: string[], deadline?: { remainingMs: () => number; }, ext?: SanitizeExtensions, notes?: string[], splices?: Array<{ placeholder: string; original: string; }>, path?: string): Promise<{ value: any; modified: boolean; sgrNote: boolean; }>; /** * The warning for a post-sanitization key collision, naming where it sits. * Exported so tests assert by reference rather than re-typing the prose. * @param {string} name the collapsed-to name * @param {string} path dotted location of the owning object ("" = top level) * @returns {string} */ export function collisionWarning(name: string, path: string): string; /** * Compose the model-facing additionalContext line for a sanitized/flagged tool * output. The seam (composeContextSeam) owns the prefix + warning join; this * binds the untrusted-ingress classification to the seam's `injectionAlert` slot * — the semantic-injection alert rides ONLY on web/MCP output, the channel where * injected natural language actually arrives (see isUntrustedIngress). On local * tools (Read, Bash, Grep, gh) the alert on a plain ANSI/secret strip is pure * noise that desensitizes the reader to the one place it matters, so it is * omitted. * @param {boolean} modified output bytes were changed (vs. flagged only) * @param {string[]} warnings * @param {string} toolName * @returns {string} */ export function composeContext(modified: boolean, warnings: string[], toolName: string): string; /** * Fail-closed replacement: a shape-matching placeholder for the parsed tool * output, or the bare `message` when stdin never parsed or carried no * tool_response (no shape to match). * @param {any} input parsed hook input, or undefined if parsing threw * @param {string} message * @returns {any} */ export function failClosedReplacement(input: any, message: string): any; /** * Whether the sanitizer's bindings actually loaded. lazyImport swallows a * missing package and yields `{}`, so the absence shows up as an undefined * binding here — NOT as a "Cannot find package" error, which the failing call * site (a TypeError on an undefined function) never carries. Testing the * binding is therefore the only detection that fires on the real condition. * @returns {boolean} */ export function sanitizerDepsLoaded(): boolean; /** * The model-facing note for a fail-closed emission. When the sanitizer's own * bindings are what is absent — a broken INSTALL rather than a broken hook, and * otherwise invisible because every later tool call then fails closed with no * stated cause — the recorded loader error and its remedy ride along. The text * comes from missingPackageMessage so this hook, the PreToolUse gate and the * prompt gate read the same words for what a missing dependency looks like. * @param {() => boolean} [depsLoaded] injectable seam for testing * @param {string} [remedy] what a reader should run; hosts pass their own * @returns {string} */ export function failClosedContext(depsLoaded?: () => boolean, remedy?: string): string; /** * Emit a fail-closed PostToolUse response, robust to the suppression itself * throwing. The shape-matching replacement walks `input.tool_response` and the * emit serializes it; a pathologically deep (but valid-JSON) tool_response * overflows that walk or `JSON.stringify`, which — left uncaught in the CLI's * own catch — would exit non-zero with NO response, and the harness would then * show the RAW, unvetted output (fail OPEN). The fallback emits the bare * `message` string instead: shallow, always serializable, and a valid string * tool_response, so the hook still fails CLOSED. `emit` is an injectable seam so * the fallback is unit-testable without a subprocess. * @param {any} input parsed hook input, or undefined if parsing threw * @param {string} message * @param {(fields: Record) => void} [emit] * @param {string} [remedy] what a reader should run; hosts pass their own * @returns {void} */ export function emitFailClosed(input: any, message: string, emit?: (fields: Record) => void, remedy?: string): void; /** * Emit the PostToolUse failure response under the CALLER's chosen posture: * fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving * the original tool output in the model's view — or the fail-closed * suppression of {@link emitFailClosed} when the caller set * AGENT_SANITIZER_FAIL_OPEN=0. * * This is the hook where the two postures diverge the most, so state the open * one plainly: several of these layers throw on inputs an attacker composes (a * nesting depth that overflows the walk, a redaction budget spent on a thousand * secret-shaped leaves), and each of those throws is * guarding content the open posture hands to the model verbatim, secrets * included. An operator who cares more about withholding a secret than about * keeping the session moving sets the knob to `0`. * @param {any} input parsed hook input, or undefined if parsing threw * @param {unknown} err * @param {(fields: Record) => void} [emit] * @param {string} [remedy] what a reader should run; hosts pass their own * @param {NodeJS.ProcessEnv | Record} [env] * @returns {void} */ export function emitHookFailure(input: any, err: unknown, emit?: (fields: Record) => void, remedy?: string, env?: NodeJS.ProcessEnv | Record): void; /** * Run the sanitization pipeline over a tool output and return the contract- * shaped verdict fields — `mutated_output` (the shape-matching sanitized value) * and/or `additional_context` (the model-facing note) — or null when there is * nothing to change (no tool output, or a clean scan). Agent-neutral by * construction: it speaks the control-plane vocabulary, never Claude's native * `updatedToolOutput`/`additionalContext` wire keys (the adapter renders those). * Every exit routes through `emit`, which announces engagement on the trace * channel (hook_ran — metadata only: hook name, tool, outcome) and returns the * fields unchanged. The trace lives here, not in the CLI block below, so it * rides the in-process, mutation-tested path. * @param {any} input the tool_name / tool_input / tool_response to sanitize * @param {SanitizeExtensions} [ext] * @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>} */ export function evaluateToolOutput(input: any, ext?: SanitizeExtensions): Promise<{ mutated_output?: unknown; additional_context?: string; } | null>; /** * Judge a normalized PostToolUse event: run the sanitization pipeline and * express its outcome as a control-plane Verdict. sanitize-output only ever * ALLOWS — the tool already ran, so this governs the model's VIEW of the * output, not the side effect. It either rewrites that view (`mutated_output`), * attaches a warning (`additional_context`), or does neither (a bare allow). * {@link evaluateToolOutput} already returns those contract fields (or null), * so the judge only stamps the `allow` decision onto them — no native-envelope * translation. Throws only if a layer engine throws (or on an UNKNOWN event); * the CLI fails closed on any throw. * @param {import("agent-control-plane-core").ToolCallEvent} event * @param {SanitizeExtensions} [ext] * @returns {Promise} */ export function judgeSanitizeOutput(event: import("agent-control-plane-core").ToolCallEvent, ext?: SanitizeExtensions): Promise; /** * Default a raw payload's `hook_event_name` to PostToolUse when it is absent. * sanitize-output is wired ONLY to the PostToolUse event, so a payload that * omits the field is a PostToolUse call by construction. The claude adapter * extracts `tool_response` (this hook's actual input) ONLY for a PostToolUse * event; without this default a field-less but legitimate payload would parse as * UNKNOWN, {@link judgeSanitizeOutput} would throw, and the CLI would fail closed * (suppress) on real tool output. A payload carrying a DIFFERENT event name is * left untouched, so the judge's UNKNOWN guard still fails closed on a genuinely * unrecognized event. * @param {unknown} input the raw stdin payload * @returns {unknown} */ export function withPostToolUseDefault(input: unknown): unknown; /** * The hook's CLI: parse → judge → render, under the caller's failure posture. * Exported so a bundle entry (which must claim the CLI slot before this module * loads) can run the exact same wiring instead of duplicating the onError * posture. That entry is also the only place a host's {@link SanitizeExtensions} * can be injected, which is why the bag enters here and not through the * environment: a callback is code, and code belongs to the composer. * @param {SanitizeExtensions} [ext] * @returns {Promise} */ export function cliMain(ext?: SanitizeExtensions): Promise; export const applyLayer1: typeof import("agent-sanitizer").applyLayer1; export const matchesSecretHint: typeof import("agent-sanitizer").matchesSecretHint; export const SECRET_HINT: RegExp; export const SECRET_HINT_EXT: RegExp; export const describeRemoved: typeof import("agent-sanitizer/output").describeRemoved; export const describeWarned: typeof import("agent-sanitizer/output").describeWarned; export const suppressToolOutput: typeof import("agent-sanitizer/output").suppressToolOutput; export const REVEAL_WITHHELD_WARNING: string; export const COLLISION_WITHHELD_MESSAGE: "[WITHHELD \u2014 this field's name collided with another after sanitization]"; export const ON_DISK_PLACEHOLDER_WARNING: string; /** * Host-supplied extensions to this hook, threaded from {@link cliMain} down to * each string leaf. Every field is optional and the bag defaults to `{}`, so a * composer that supplies none gets exactly the behavior of this module alone — * which is what lets the extension points ship without changing any shipped * verdict. The callbacks own all policy: this module decides only WHERE they run, * never WHETHER their result is applied. * * A callback that throws is not caught here. That is deliberate: the throw lands * in the CLI's fail-closed catch and the tool output is suppressed, so a broken * extension cannot degrade into showing unvetted output. * * These are NOT the Layer-5 injection-filter seam and do not inherit its * delete-only, closed-enum restriction. (Its identifier is deliberately unspoken * here: the plugin-bundle suite pins Layer 5 absent by asserting the name appears * nowhere in this file, and that absolute check is worth more than the precision * of one comment.) That restriction exists because such a * filter is a MODEL, so its output is attacker-reachable and must not be able to * inject text into the model-facing context. These callbacks are code the * composer wrote and linked at build time — the same trust level as the injected * redactor — so free-text warnings and arbitrary rewrites are theirs to own. */ export type SanitizeExtensions = { /** * Runs once per string leaf, AFTER Layers 1-4. Returning `cleaned` replaces the * model-facing text; `warning` joins this leaf's warnings. */ postText?: ((cleaned: string, ctx: { toolName: string; webIngress: boolean; deadline: { remainingMs: () => number; }; }) => Promise<{ cleaned?: string; warning?: string; } | null | undefined> | { cleaned?: string; warning?: string; } | null | undefined) | undefined; /** * Given the pre-redaction text of a leaf that tripped Layer 4, returns a note * appended to that leaf's "API keys/secrets redacted: …" warning. */ redactNote?: ((raw: string) => string | undefined) | undefined; /** * Awaited once per judged event that carried a tool response, with the output * the model will actually see. `session_id` is the harness's session identity, * lifted from the event's `meta` — an audit trail that cannot say WHICH session * produced a record cannot be read back per-session, and the tool fields alone * do not carry it. Absent when the payload omitted it. */ audit?: ((record: { tool: string | null; session_id?: string; modified: boolean; output: unknown; context?: string; }) => Promise | void) | undefined; /** * Where this hook announces engagement. A host that already runs a trace * channel under its own environment variables passes its sink here, so the * announcement lands where its detector reads instead of on this package's * channel. Defaults to lib/trace.mjs's `trace`. */ trace?: import("./lib/trace.mjs").TraceFn | undefined; /** * What a reader should run when the sanitizer's own bindings are what is * missing. This hook's host channel is `ext`, where the other two gates use a * frozen message table; either way it is one channel per gate, so a host * cannot supply its wording somewhere the fail-closed context never reads. */ remedy?: string | undefined; }; import { chargeHostExtension } from "./lib/hook-timing.mjs"; import { chargeHostExtensionSync } from "./lib/hook-timing.mjs"; export { chargeHostExtension, chargeHostExtensionSync };