agent-sanitizer 2.60.0 → 2.60.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.
@@ -67,7 +67,7 @@ import { containsPlaceholder, layer2Keys } from "./lib/placeholder-grammar.mjs";
67
67
  // Layer-1 primitives and the cheap pre-gates, bound via lazyImport (see its
68
68
  // doc for the fail-OPEN hazard of a bare static npm import). A load failure
69
69
  // leaves the bindings undefined and the dependent call throws into the CLI's
70
- // fail-closed catch, which suppresses the output.
70
+ // catch, which applies this hook's fault posture (emitHookFailure).
71
71
  // HTML_TAG_PRESENT (the Layer-2 pre-gate) and the Layer-1 re-exports come from
72
72
  // the package ROOT, which exposes them WITHOUT eagerly loading the
73
73
  // remark/rehype/unified graph (~120ms of module-load time). Importing `/html`
@@ -194,8 +194,8 @@ async function redactSecrets(text, webIngress, deadline) {
194
194
  * never WHETHER their result is applied.
195
195
  *
196
196
  * A callback that throws is not caught here. That is deliberate: the throw lands
197
- * in the CLI's fail-closed catch and the tool output is suppressed, so a broken
198
- * extension cannot degrade into showing unvetted output.
197
+ * in the CLI's catch like any other sanitizer fault, so a broken extension gets
198
+ * exactly the posture the operator chose (emitHookFailure) and no quieter one.
199
199
  *
200
200
  * These are NOT the Layer-5 injection-filter seam and do not inherit its
201
201
  * delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
@@ -246,6 +246,8 @@ async function redactSecrets(text, webIngress, deadline) {
246
246
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
247
247
  * all leaves of one hook run; a direct caller gets a fresh full budget
248
248
  * @param {SanitizeExtensions} [ext]
249
+ * @param {boolean} [secretsOn] the secret opt-in, decided ONCE per hook run by
250
+ * {@link evaluateToolOutput} and threaded down; a direct caller reads the env
249
251
  * @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }>}
250
252
  */
251
253
  export async function sanitizeText(
@@ -253,6 +255,7 @@ export async function sanitizeText(
253
255
  toolName,
254
256
  deadline = makeDeadline(SANITIZE_BUDGET_MS),
255
257
  ext = {},
258
+ secretsOn = secretsEnabled(),
256
259
  ) {
257
260
  const webIngress = isUntrustedIngress(toolName);
258
261
  // Layer 2 (HTML rewrite) runs on WebFetch/WebSearch always, and on MCP output
@@ -275,7 +278,7 @@ export async function sanitizeText(
275
278
  flagDigestValues: digestFlaggingEnabled(),
276
279
  sgrCarveOut: !webIngress,
277
280
  deadline,
278
- // Layer 4 — OPT-IN (secretsEnabled): with the knob unset the seam gets no
281
+ // Layer 4 — OPT-IN (`secretsOn`): with the knob unset the seam gets no
279
282
  // redact callback at all, so plain output never spawns the daemon and no
280
283
  // placeholder ever enters the model's view. When it runs, the seam rethrows
281
284
  // a redactor throw wrapped, and this hook's fault policy suppresses the
@@ -283,7 +286,7 @@ export async function sanitizeText(
283
286
  // operator's terminal here first: the suppression note rides in what only
284
287
  // the model sees, so a degraded redactor would otherwise be invisible to
285
288
  // the human.
286
- redact: !secretsEnabled()
289
+ redact: !secretsOn
287
290
  ? undefined
288
291
  : async (/** @type {string} */ content) => {
289
292
  let secrets;
@@ -389,6 +392,7 @@ function applyPostText(result, post) {
389
392
  * after `notes` for the same positional-compatibility reason
390
393
  * @param {string} [path] dotted location of `value` within the tool output,
391
394
  * used only to name a key collision's location in its warning
395
+ * @param {boolean} [secretsOn] the secret opt-in (see {@link sanitizeText})
392
396
  * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
393
397
  */
394
398
  export async function sanitizeValue(
@@ -401,9 +405,16 @@ export async function sanitizeValue(
401
405
  notes = [],
402
406
  splices = [],
403
407
  path = "",
408
+ secretsOn = secretsEnabled(),
404
409
  ) {
405
410
  if (typeof value === "string") {
406
- const result = await sanitizeText(value, toolName, deadline, ext);
411
+ const result = await sanitizeText(
412
+ value,
413
+ toolName,
414
+ deadline,
415
+ ext,
416
+ secretsOn,
417
+ );
407
418
  warnings.push(...result.warnings);
408
419
  notes.push(...result.notes);
409
420
  if (result.reveal !== undefined) reveals.push(result.reveal);
@@ -429,6 +440,7 @@ export async function sanitizeValue(
429
440
  notes,
430
441
  splices,
431
442
  `${path}[${index}]`,
443
+ secretsOn,
432
444
  );
433
445
  out.push(result.value);
434
446
  if (result.modified) modified = true;
@@ -447,6 +459,7 @@ export async function sanitizeValue(
447
459
  notes,
448
460
  splices,
449
461
  path,
462
+ secretsOn,
450
463
  );
451
464
  return { value, modified: false, sgrNote: false };
452
465
  }
@@ -529,7 +542,8 @@ function withheldKeyFor(out, cleaned) {
529
542
  * @param {string[]} notes accumulates the leaves' NOTE-severity findings
530
543
  * @param {Array<{ placeholder: string, original: string }>} splices accumulates
531
544
  * the leaves' Layer-2 placeholder→original pairs
532
- * @param {string} [path] dotted location of this object in the tool output
545
+ * @param {string} path dotted location of this object in the tool output
546
+ * @param {boolean} secretsOn the secret opt-in (see {@link sanitizeText})
533
547
  * @returns {Promise<{ value: Record<string, any>, modified: boolean, sgrNote: boolean }>}
534
548
  */
535
549
  async function sanitizeObject(
@@ -541,7 +555,8 @@ async function sanitizeObject(
541
555
  ext,
542
556
  notes,
543
557
  splices,
544
- path = "",
558
+ path,
559
+ secretsOn,
545
560
  ) {
546
561
  /** @type {Record<string, any>} */
547
562
  const out = {};
@@ -556,7 +571,13 @@ async function sanitizeObject(
556
571
  // callback sees just the string and the tool, so it cannot tell a schema key
557
572
  // from content; a rewrite here can collapse two fields into one name, which
558
573
  // costs both of them their values below. Extensions run on value leaves.
559
- const keyResult = await sanitizeText(key, toolName, deadline);
574
+ const keyResult = await sanitizeText(
575
+ key,
576
+ toolName,
577
+ deadline,
578
+ {},
579
+ secretsOn,
580
+ );
560
581
  warnings.push(...keyResult.warnings);
561
582
  notes.push(...keyResult.notes);
562
583
  if (keyResult.reveal !== undefined) reveals.push(keyResult.reveal);
@@ -573,6 +594,7 @@ async function sanitizeObject(
573
594
  notes,
574
595
  splices,
575
596
  path === "" ? keyResult.cleaned : `${path}.${keyResult.cleaned}`,
597
+ secretsOn,
576
598
  );
577
599
  // Two distinct raw keys can sanitize to the same name (e.g. `token` and a
578
600
  // `token` carrying a zero-width space stripped by Layer 1). Overwriting
@@ -908,6 +930,11 @@ export async function evaluateToolOutput(input, ext = {}) {
908
930
  const reveals = [];
909
931
  /** @type {Array<{ placeholder: string, original: string }>} */
910
932
  const splices = [];
933
+ // THE secret opt-in decision for this run. Every secret-touching path below —
934
+ // the walk's Layer 4, the reveal and span persistence, the on-disk tripwire,
935
+ // and the prose that claims redaction — reads this one value, so no path can
936
+ // dial the daemon or promise "redacted" while another treats the layer as off.
937
+ const secretsOn = secretsEnabled();
911
938
  // One shared wall-clock budget for every blocking daemon call this hook makes —
912
939
  // across all leaves of the walk AND the reveal/span-redaction loops below — so
913
940
  // their SUM cannot pile up past the hook kill (see SANITIZE_BUDGET_MS).
@@ -925,6 +952,8 @@ export async function evaluateToolOutput(input, ext = {}) {
925
952
  ext,
926
953
  notes,
927
954
  splices,
955
+ "",
956
+ secretsOn,
928
957
  );
929
958
  // Persist each leaf's pre-Layer-2 text (deduped by content) so the model can
930
959
  // Read back what the HTML splice removed; a successful write appends a hint
@@ -937,10 +966,10 @@ export async function evaluateToolOutput(input, ext = {}) {
937
966
  try {
938
967
  // Same opt-in as the main pass: with secrets off nothing was redacted
939
968
  // out of the primary output either, so the sidecar persists verbatim.
940
- const secrets = secretsEnabled()
969
+ const redacted = secretsOn
941
970
  ? await redactSecrets(original, true, deadline)
942
971
  : null;
943
- stored = secrets ? secrets.text : original;
972
+ stored = redacted ? redacted.text : original;
944
973
  } catch {
945
974
  // The pre-splice text carries the spliced hidden-element content, so a
946
975
  // secret hidden only inside such an element reaches the redactor here
@@ -952,16 +981,16 @@ export async function evaluateToolOutput(input, ext = {}) {
952
981
  warnings.push(REVEAL_WITHHELD_WARNING);
953
982
  continue;
954
983
  }
955
- const hint = persistReveal(stored);
984
+ const hint = persistReveal(stored, secretsOn);
956
985
  if (hint) warnings.push(hint);
957
986
  }
958
987
  // Persist each splice's original beside the reveal, keyed by the placeholder's
959
988
  // content-addressed key, so the PreToolUse rehydrator can restore a keyed
960
989
  // placeholder the model writes back (Edit/Write) to the original bytes. Same
961
- // re-redaction treatment as the reveals above — the seam already vetted each
962
- // `original` on its way out, but this loop re-runs strict web-ingress
963
- // redaction so NOTHING lands on disk that did not pass the same bar as the
964
- // reveal sidecar. The key is EXTRACTED from the placeholder, never recomputed
990
+ // re-redaction treatment as the reveals above, under the same opt-in — the
991
+ // seam already vetted each `original` on its way out, but this loop re-runs
992
+ // strict web-ingress redaction so NOTHING lands on disk that did not pass the
993
+ // same bar as the reveal sidecar. The key is EXTRACTED from the placeholder, never recomputed
965
994
  // from the redacted original: it was minted from the RAW original's sha256,
966
995
  // so it is a name, not an integrity check — hashing the redacted bytes would
967
996
  // mint a key no placeholder carries. Persistence failure is non-fatal (the
@@ -973,14 +1002,16 @@ export async function evaluateToolOutput(input, ext = {}) {
973
1002
  if (key === undefined) continue;
974
1003
  let stored;
975
1004
  try {
976
- const secrets = await redactSecrets(original, true, deadline);
977
- stored = secrets ? secrets.text : original;
1005
+ const redacted = secretsOn
1006
+ ? await redactSecrets(original, true, deadline)
1007
+ : null;
1008
+ stored = redacted ? redacted.text : original;
978
1009
  } catch {
979
1010
  // Same doctrine as the reveal loop: never write unvetted text, never
980
1011
  // fail the already-safe primary output over a convenience sidecar.
981
1012
  continue;
982
1013
  }
983
- if (persistSpan(key, stored)) spanStored = true;
1014
+ if (persistSpan(key, stored, secretsOn)) spanStored = true;
984
1015
  }
985
1016
  if (spanStored) warnings.push(SPAN_ROUNDTRIP_NOTICE);
986
1017
  // On-disk placeholder tripwire (see ON_DISK_PLACEHOLDER_WARNING). Tested on
@@ -993,7 +1024,7 @@ export async function evaluateToolOutput(input, ext = {}) {
993
1024
  // inserts placeholders, so placeholder-shaped bytes are ordinary text and
994
1025
  // the warning would be pure noise on every doc ABOUT redaction.
995
1026
  if (
996
- secretsEnabled() &&
1027
+ secretsOn &&
997
1028
  input.tool_name === "Read" &&
998
1029
  !revealRead &&
999
1030
  containsPlaceholder(toolOutput)
@@ -1045,17 +1076,18 @@ export async function evaluateToolOutput(input, ext = {}) {
1045
1076
  * {@link evaluateToolOutput} already returns those contract fields (or null),
1046
1077
  * so the judge only stamps the `allow` decision onto them — no native-envelope
1047
1078
  * translation. Throws only if a layer engine throws (or on an UNKNOWN event);
1048
- * the CLI fails closed on any throw.
1079
+ * the CLI applies this hook's fault posture to any throw (emitHookFailure).
1049
1080
  * @param {import("agent-control-plane-core").ToolCallEvent} event
1050
1081
  * @param {SanitizeExtensions} [ext]
1051
1082
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
1052
1083
  */
1053
1084
  export async function judgeSanitizeOutput(event, ext = {}) {
1054
1085
  const { Decision, EventKind } = controlPlane();
1055
- // Fail closed on a payload the adapter cannot classify (contract/harness
1056
- // drift): this hook only ever receives PostToolUse, so an UNKNOWN event is an
1057
- // anomaly, and abstaining would let its output reach the model UNSANITIZED —
1058
- // fail OPEN. Throwing lands in the CLI's catch, which suppresses the output.
1086
+ // Throw on a payload the adapter cannot classify (contract/harness drift):
1087
+ // this hook only ever receives PostToolUse, so an UNKNOWN event is an
1088
+ // anomaly, and abstaining would report a CLEAN run on output nobody vetted.
1089
+ // Throwing lands in the CLI's catch, which applies this hook's registered
1090
+ // fault policy (lib/hook-fault.mjs, rendered by emitHookFailure).
1059
1091
  if (event.event === EventKind.UNKNOWN)
1060
1092
  throw new Error(
1061
1093
  "sanitize-output: unrecognized hook payload (not PostToolUse)",
@@ -1108,9 +1140,9 @@ export async function judgeSanitizeOutput(event, ext = {}) {
1108
1140
  * omits the field is a PostToolUse call by construction. The claude adapter
1109
1141
  * extracts `tool_response` (this hook's actual input) ONLY for a PostToolUse
1110
1142
  * event; without this default a field-less but legitimate payload would parse as
1111
- * UNKNOWN, {@link judgeSanitizeOutput} would throw, and the CLI would fail closed
1112
- * (suppress) on real tool output. A payload carrying a DIFFERENT event name is
1113
- * left untouched, so the judge's UNKNOWN guard still fails closed on a genuinely
1143
+ * UNKNOWN, {@link judgeSanitizeOutput} would throw, and the CLI would take the
1144
+ * fault posture on real tool output. A payload carrying a DIFFERENT event name
1145
+ * is left untouched, so the judge's UNKNOWN guard still fires on a genuinely
1114
1146
  * unrecognized event.
1115
1147
  * @param {unknown} input the raw stdin payload
1116
1148
  * @returns {unknown}
@@ -1147,15 +1179,9 @@ export async function cliMain(ext = {}) {
1147
1179
  (event) => judgeSanitizeOutput(event, ext),
1148
1180
  {
1149
1181
  transformInput: withPostToolUseDefault,
1150
- // Fail closed: replace every string leaf of the original output with the
1151
- // placeholder, preserving shape so the harness honors the suppression
1152
- // instead of falling back to the raw, unvetted output (runJudgeCli hands
1153
- // back the parsed `input` even when the control-plane load failed, so the
1154
- // suppression shape-matches the real tool_response). emitFailClosed itself
1155
- // falls back to a bare string if that shape-matching replacement or its
1156
- // serialization throws, so even a pathological input fails closed. Under
1157
- // the open default with Layer 4 off the caller gets the warning-only
1158
- // pass-through instead — see emitHookFailure.
1182
+ // The registered fault policy, rendered by emitHookFailure. It needs the
1183
+ // parsed `input`, which runJudgeCli hands back even when the control-plane
1184
+ // load failed, so a suppressing arm can keep the output's shape.
1159
1185
  onError: (err, input) =>
1160
1186
  emitHookFailure(input, err, undefined, ext.remedy),
1161
1187
  },
@@ -38,10 +38,10 @@ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
38
38
  // come from the agent-sanitizer package. They are bound by a *caught* dynamic
39
39
  // import, never a bare top-level `import … from "…"`: a static npm import
40
40
  // resolves before any try/catch, so a missing node_modules would crash this hook
41
- // at load and let the prompt through UNSANITIZED (fail-open). A failed load
41
+ // at load and let the prompt through UNSANITIZED with no warning. A failed load
42
42
  // leaves that binding undefined, which the judge's typeof guards turn into a
43
- // fail-closed block — one guard each, since the two loads succeed or fail
44
- // independently.
43
+ // throw that this hook's registered fault policy answers (lib/hook-fault.mjs)
44
+ // — one guard each, since the two loads succeed or fail independently.
45
45
  /** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
46
46
  export let classifyPrompt;
47
47
  /** @type {typeof import("agent-sanitizer").stripAnsiFully} */
@@ -101,7 +101,7 @@ registerFaultPolicy(HOOK_NAME, {
101
101
  /* c8 ignore start — module-load boundary: the imports resolve in every real
102
102
  * run, and their failure (the package absent) can't be simulated in-process, so
103
103
  * neither arm is observable to the in-process tests. The judge's typeof guard
104
- * converts an undefined stripper into a fail-closed block — that guard IS tested. */
104
+ * converts an undefined stripper into a fault — that guard IS tested. */
105
105
  // Stryker disable all
106
106
  // lazyImport rather than a bare `await import` in a try/catch: it RECORDS the
107
107
  // loader error, which is the only thing that can tell a missing install apart
@@ -109,7 +109,7 @@ registerFaultPolicy(HOOK_NAME, {
109
109
  // missingPackageError with nothing to report but a guess.
110
110
  // The cast asserts the loaded SHAPE, not that it loaded: lazyImport yields {} on
111
111
  // failure, so either binding can still be undefined here — which is exactly what
112
- // the judge's typeof guard turns into a fail-closed block.
112
+ // the judge's typeof guard turns into a fault (see the posture note above).
113
113
  ({ classifyPrompt } = /** @type {typeof import("agent-sanitizer/prompt")} */ (
114
114
  await lazyImport("agent-sanitizer/prompt")
115
115
  ));
@@ -123,12 +123,11 @@ registerFaultPolicy(HOOK_NAME, {
123
123
  * Judge a normalized prompt-submit event. Agent-agnostic: consumes the
124
124
  * control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
125
125
  * renders through any agent adapter, not just Claude's. Throws (into the
126
- * calling hook's catch) when the sanitizer package never loaded — this hook is
127
- * the only defense on user input, so a prompt it cannot classify must block,
128
- * never pass through.
126
+ * calling hook's catch) when the sanitizer package never loaded, so a prompt it
127
+ * cannot classify takes the hook's fault posture rather than a silent ALLOW.
129
128
  * @param {import("agent-control-plane-core").ToolCallEvent} event
130
129
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
131
- * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
130
+ * to the package's stripAnsiFully; injectable so the fault path is testable)
132
131
  * @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
133
132
  * merged over the defaults so a partial table can never leave a field unset
134
133
  * @returns {import("agent-control-plane-core").Verdict}
@@ -199,7 +198,7 @@ export function judgeSanitizeUserPrompt(
199
198
  * env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
200
199
  * }} [opts]
201
200
  * `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
202
- * injectable so the fail-closed path is testable); `overrides` are reason
201
+ * injectable so the fault path is testable); `overrides` are reason
203
202
  * overrides, merged over the defaults so a partial table can never leave a field
204
203
  * unset; `trace` is where engagement is announced, for a host with its own trace
205
204
  * channel (see lib/trace.mjs); `env` is the failure-posture source (see
@@ -221,9 +220,8 @@ export async function main(read, write, opts = {}) {
221
220
  // runJudgeCli so this hook doesn't re-implement the control-plane boundary:
222
221
  // runJudgeCli reads stdin BEFORE loading the control-plane package, so a
223
222
  // load failure fails to this hook's posture (the onError block) instead of
224
- // leaving stdin unread. onError writes its envelope by hand — the default
225
- // warning-only pass-through, or the block a caller asked for with
226
- // AGENT_SANITIZER_FAIL_OPEN=0 — because the adapter that would render it is
223
+ // leaving stdin unread. onError writes the registered fault policy's envelope
224
+ // (lib/hook-fault.mjs) by hand, because the adapter that would render it is
227
225
  // exactly what may have failed to load. Either way this is the HOOK failing;
228
226
  // a prompt the working stripper flagged is still blocked in both postures.
229
227
  await runJudgeCli(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.60.0",
3
+ "version": "2.60.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/html.mjs CHANGED
@@ -28,7 +28,11 @@
28
28
  * remark/rehype/unified graph costs ~200ms of module-load time, so the main
29
29
  * entry `await import()`s this module only when its cheap regex gates match.
30
30
  */
31
- import { createHash } from "node:crypto";
31
+ import {
32
+ layer2Placeholder,
33
+ LAYER2_PLACEHOLDER_RE,
34
+ UNPARSEABLE_PLACEHOLDER,
35
+ } from "./layer2-placeholder.mjs";
32
36
  // Subpath specifiers, never the `css-tree` root: the root builds the default
33
37
  // syntax at module scope, that construction reaches the lexer's `lib/data.js`,
34
38
  // and `lib/data.js` pulls the whole `mdn-data` CSS table set in through
@@ -1582,48 +1586,10 @@ export function closingTagName(htmlValue) {
1582
1586
  * string indices, i.e. UTF-16 code units.
1583
1587
  */
1584
1588
 
1585
- // The kind-specific prose of a Layer-2 placeholder. The full placeholder is
1586
- // built by {@link layer2Placeholder} and matched by {@link LAYER2_PLACEHOLDER_RE};
1587
- // keep all three in sync — they are ONE grammar shared with the rehydrating
1588
- // hooks and the tests.
1589
- const PLACEHOLDER_LABEL = Object.freeze({
1590
- hidden: "hidden HTML",
1591
- comment: "HTML comment",
1592
- });
1593
-
1594
- // How many lowercase-hex chars of the sha256 make the placeholder key. 48 bits
1595
- // is far past accidental-collision range for the handful of splices one
1596
- // document carries, while keeping the placeholder short enough to read.
1597
- const PLACEHOLDER_KEY_LEN = 12;
1598
-
1599
- /**
1600
- * The keyed, content-addressed placeholder for one Layer-2 splice:
1601
- * `[hidden HTML removed #<key>]` / `[HTML comment removed #<key>]`, where
1602
- * `<key>` is the first 12 lowercase-hex chars of sha256 over the UTF-8
1603
- * encoding of the ORIGINAL spliced text. Content-addressed on purpose:
1604
- * identical spliced content yields the identical placeholder, so a rehydrator
1605
- * can match placeholder → original by key alone, and duplicated content never
1606
- * produces conflicting keys.
1607
- * @param {SpliceKind} kind
1608
- * @param {string} original the exact text the splice removed
1609
- * @returns {string}
1610
- */
1611
- export function layer2Placeholder(kind, original) {
1612
- const key = createHash("sha256")
1613
- .update(original, "utf8")
1614
- .digest("hex")
1615
- .slice(0, PLACEHOLDER_KEY_LEN);
1616
- return `[${PLACEHOLDER_LABEL[kind]} removed #${key}]`;
1617
- }
1618
-
1619
- /**
1620
- * The single grammar definition for keyed Layer-2 placeholders — the exact
1621
- * output of {@link layer2Placeholder}, capture group 1 = the key. Global so
1622
- * callers can scan a document for every placeholder; reset `lastIndex` (or
1623
- * use `matchAll`) between uses.
1624
- */
1625
- export const LAYER2_PLACEHOLDER_RE =
1626
- /\[(?:hidden HTML|HTML comment) removed #([0-9a-f]{12})\]/g;
1589
+ // The Layer-2 placeholder grammar lives in the dependency-free
1590
+ // ./layer2-placeholder.mjs so the hooks can parse placeholders without loading
1591
+ // this module's parser graph; it stays part of this subpath's public API.
1592
+ export { layer2Placeholder, LAYER2_PLACEHOLDER_RE, UNPARSEABLE_PLACEHOLDER };
1627
1593
 
1628
1594
  // DEPRECATED un-keyed placeholder PREFIXES, kept exported for callers that
1629
1595
  // match "some Layer-2 placeholder of this kind" without knowing the key. The
@@ -1631,15 +1597,6 @@ export const LAYER2_PLACEHOLDER_RE =
1631
1597
  // with {@link LAYER2_PLACEHOLDER_RE}.
1632
1598
  export const HIDDEN_PLACEHOLDER = "[hidden HTML removed";
1633
1599
  export const COMMENT_PLACEHOLDER = "[HTML comment removed";
1634
- // Shown when the remark/rehype parse itself fails (e.g. pathologically nested
1635
- // markup overflows the recursive tree walk with a RangeError). The top-level
1636
- // `sanitize`/`sanitizeText` contract is "never throws, `cleaned` is always a
1637
- // string", and this module is the only seam those callers own — so the HTML
1638
- // layer must fail CLOSED here: withhold the whole unparseable input behind one
1639
- // placeholder rather than let the exception escape and suppress all tool
1640
- // output. Withholding (not passing through) is the safe choice — content we
1641
- // could not inspect for hidden payloads is treated as if it were hidden.
1642
- export const UNPARSEABLE_PLACEHOLDER = "[HTML unparseable — withheld]";
1643
1600
 
1644
1601
  /**
1645
1602
  * `ranges` sorted by start with every overlap unioned — one entry per
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The Layer-2 placeholder grammar: the one producer of keyed splice
3
+ * placeholders, the regex that parses them, and the un-keyed marker for a
4
+ * withheld unparseable input. `./html.mjs` re-exports all three as its public
5
+ * API; the hooks reach them through claude-hooks/lib/placeholder-grammar.mjs.
6
+ *
7
+ * Split out of `./html.mjs` because that module loads the remark/rehype/unified
8
+ * graph (~200ms) at import, and the hooks that only PARSE placeholders — the
9
+ * PreToolUse rehydrator on every Edit/Write, the PostToolUse span persistence —
10
+ * must not pay for it. This module imports nothing but `node:crypto`.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+
14
+ // The kind-specific prose of a Layer-2 placeholder. The full placeholder is
15
+ // built by {@link layer2Placeholder} and matched by {@link LAYER2_PLACEHOLDER_RE};
16
+ // keep all three in sync — they are ONE grammar.
17
+ const PLACEHOLDER_LABEL = Object.freeze({
18
+ hidden: "hidden HTML",
19
+ comment: "HTML comment",
20
+ });
21
+
22
+ // How many lowercase-hex chars of the sha256 make the placeholder key. 48 bits
23
+ // is far past accidental-collision range for the handful of splices one
24
+ // document carries, while keeping the placeholder short enough to read.
25
+ const PLACEHOLDER_KEY_LEN = 12;
26
+
27
+ /**
28
+ * The keyed, content-addressed placeholder for one Layer-2 splice:
29
+ * `[hidden HTML removed #<key>]` / `[HTML comment removed #<key>]`, where
30
+ * `<key>` is the first 12 lowercase-hex chars of sha256 over the UTF-8
31
+ * encoding of the ORIGINAL spliced text. Content-addressed on purpose:
32
+ * identical spliced content yields the identical placeholder, so a rehydrator
33
+ * can match placeholder → original by key alone, and duplicated content never
34
+ * produces conflicting keys.
35
+ * @param {"comment" | "hidden"} kind
36
+ * @param {string} original the exact text the splice removed
37
+ * @returns {string}
38
+ */
39
+ export function layer2Placeholder(kind, original) {
40
+ const key = createHash("sha256")
41
+ .update(original, "utf8")
42
+ .digest("hex")
43
+ .slice(0, PLACEHOLDER_KEY_LEN);
44
+ return `[${PLACEHOLDER_LABEL[kind]} removed #${key}]`;
45
+ }
46
+
47
+ /**
48
+ * The single grammar definition for keyed Layer-2 placeholders — the exact
49
+ * output of {@link layer2Placeholder}, capture group 1 = the key. Global so
50
+ * callers can scan a document for every placeholder; reset `lastIndex` (or
51
+ * use `matchAll`) between uses.
52
+ */
53
+ export const LAYER2_PLACEHOLDER_RE =
54
+ /\[(?:hidden HTML|HTML comment) removed #([0-9a-f]{12})\]/g;
55
+
56
+ // Shown when the remark/rehype parse itself fails (e.g. a RangeError from
57
+ // pathologically nested markup). `sanitize` never throws, so the HTML layer
58
+ // fails CLOSED by withholding the whole input behind this one placeholder:
59
+ // content it could not inspect for hidden payloads is treated as hidden.
60
+ export const UNPARSEABLE_PLACEHOLDER = "[HTML unparseable — withheld]";
@@ -6,60 +6,40 @@
6
6
  * @returns {boolean}
7
7
  */
8
8
  export function needsFixedPoint(layers: Layer[]): boolean;
9
+ /**
10
+ * The `fault` reason for a body that never settled, naming the stages still
11
+ * rewriting on the last pass so a reader knows which one oscillates.
12
+ * @param {string[]} stillChanging
13
+ * @returns {string}
14
+ */
15
+ export function nonConvergenceReason(stillChanging: string[]): string;
9
16
  /**
10
17
  * Run a declared layer chain over one tool input.
11
18
  *
12
- * Returns the final input, whether anything changed, and the model-facing notes
13
- * in the order they were produced (deduplicated: a fixed-point re-run that
14
- * repeats a layer's note would otherwise say the same thing twice). A layer that
15
- * denies ends the run immediately, with `deny` set.
19
+ * Returns the final input, whether any stage really changed it, and the
20
+ * model-facing notes of those stages, in order and deduplicated (a fixed-point
21
+ * re-run would otherwise repeat a note). A stage
22
+ * that denies ends the run immediately, with `deny` set. A body that never
23
+ * settles sets `fault` (the reason) alongside the last pass's sanitized
24
+ * `updatedInput`, and still runs the terminal stages, so a call that proceeds
25
+ * carries the same rehydration a settled one would. The verdict is the
26
+ * caller's: this driver renders none.
16
27
  * @param {string} tool
17
28
  * @param {any} toolInput
18
29
  * @param {Layer[]} layers
19
- * @returns {Promise<{ updatedInput: any, changed: boolean, contexts: string[], deny?: string }>}
30
+ * @returns {Promise<{ updatedInput: any, changed: boolean, contexts: string[], deny?: string, fault?: string }>}
20
31
  */
21
32
  export function runLayerPipeline(tool: string, toolInput: any, layers: Layer[]): Promise<{
22
33
  updatedInput: any;
23
34
  changed: boolean;
24
35
  contexts: string[];
25
36
  deny?: string;
37
+ fault?: string;
26
38
  }>;
27
- /**
28
- * The PreToolUse layer chain as a DECLARED pipeline instead of a run of
29
- * sequential statements.
30
- *
31
- * The confusable fold (Layer 2) carries a soundness precondition: it deliberately
32
- * SKIPS a token that still holds an unmapped non-ASCII glyph, because such a
33
- * token can never come out byte-equal to an ASCII deny-rule target, so folding it
34
- * would only mangle real foreign-language text. That argument holds only while no
35
- * later layer ERASES code points from the same field — if it does, the glyph the
36
- * fold relied on can disappear after the decision was taken.
37
- *
38
- * Layer 3 (authored-content stripping) erases exactly that: payload-capable
39
- * invisible characters. Running it after the fold made the precondition false,
40
- * and the gap was reachable: `cat /etc/p<CYRILLIC A><12 x ZWSP>sswd` put the
41
- * zero-width run inside the token, so the fold skipped it, then the strip removed
42
- * the padding and emitted `cat /etc/p<CYRILLIC A>sswd` — the homoglyph intact and
43
- * the evidence for skipping it gone. Zero-width padding suppressed the fold and
44
- * the next layer erased the reason.
45
- *
46
- * The eliminator is structural: layers declare whether they ERASE code points and
47
- * whether their decision is SKIP-BASED (suppressible by code points another layer
48
- * erases), and this driver enforces the precondition rather than a comment
49
- * asking a future reader to preserve it. When the table puts an erasing layer
50
- * after a skip-based one, the driver re-runs the body to a FIXED POINT, so the
51
- * emitted value is one every skip-based layer has seen in its final form. A table
52
- * that needs no fixed point runs exactly once, so the cost is paid only by the
53
- * ordering that creates the hazard.
54
- *
55
- * Layers may be `terminal`: run once, after the fixed point, never re-run. That
56
- * is Layer 4 (rehydration), whose whole contract is that it sees the FINAL
57
- * authored text and its restored secrets are not re-stripped by Layer 3. Terminal
58
- * layers must be a suffix of the table, which the driver checks.
59
- */
60
39
  /**
61
40
  * A layer's result: the rewritten input plus the model-facing note, a `deny`
62
- * verdict that ends the pipeline, or null when the layer changed nothing.
41
+ * verdict that ends the pipeline, or null when the layer changed nothing. A
42
+ * rewrite deep-equal to its input counts as null, whatever the layer reports.
63
43
  * @typedef {{ updatedInput: any, context: string } | { deny: string } | null} LayerResult
64
44
  */
65
45
  /**
@@ -79,16 +59,17 @@ export function runLayerPipeline(tool: string, toolInput: any, layers: Layer[]):
79
59
  */
80
60
  /**
81
61
  * Bound on fixed-point passes. Each pass either changes the input or ends the
82
- * loop, and the layers are contractive in practice (folding and stripping both
83
- * shrink the space of remaining findings), so a run that is still changing after
84
- * this many passes is a layer that oscillates — a bug. Throwing hands it to the
85
- * hook's fail-closed catch, which is the loud outcome; looping forever would be
86
- * silently killed by the harness and read as a non-blocking pass (fail OPEN).
62
+ * loop, and the stages are contractive in practice (folding and stripping both
63
+ * shrink the space of remaining findings), so a body still changing after this
64
+ * many passes holds a stage that oscillates. That yields a `fault` (see
65
+ * {@link runLayerPipeline}); looping forever would instead be killed by the
66
+ * harness, which reads a killed PreToolUse hook as a non-blocking pass.
87
67
  */
88
68
  export const MAX_PIPELINE_PASSES: 8;
89
69
  /**
90
70
  * A layer's result: the rewritten input plus the model-facing note, a `deny`
91
- * verdict that ends the pipeline, or null when the layer changed nothing.
71
+ * verdict that ends the pipeline, or null when the layer changed nothing. A
72
+ * rewrite deep-equal to its input counts as null, whatever the layer reports.
92
73
  */
93
74
  export type LayerResult = {
94
75
  updatedInput: any;
@@ -94,29 +94,6 @@ export const PLACEHOLDER_LABEL_CHARS: "A-Za-z0-9 ()._-";
94
94
  * `grep "\[REDACTED"`) is not mistaken for a placeholder.
95
95
  */
96
96
  export const PLACEHOLDER_RE: RegExp;
97
- /**
98
- * The keyed Layer-2 splice placeholder grammar — `[hidden HTML removed #<key>]`
99
- * / `[HTML comment removed #<key>]`, capture group 1 = the 12-lowercase-hex key
100
- * (the first 12 hex chars of sha256 over the RAW spliced original). Mirrored
101
- * from the single producer in the engine (`src/html.mjs`,
102
- * `LAYER2_PLACEHOLDER_RE`) for the same pinned-registry reason as
103
- * PLACEHOLDER_RE above.
104
- *
105
- * Deliberately DISJOINT from the secret-redaction grammar: a Layer-2
106
- * placeholder never matches PLACEHOLDER_RE (no `[REDACTED` prefix) and a
107
- * secret placeholder never matches this — so the secret rehydrator and the
108
- * Layer-2 rehydrator can compose without either touching the other's tokens.
109
- */
110
- export const LAYER2_PLACEHOLDER_RE: RegExp;
111
- /**
112
- * The one Layer-2 marker that carries no key, mirrored from src/html.mjs
113
- * (UNPARSEABLE_PLACEHOLDER) for the bundle-pin reason above. The fail-closed
114
- * path withholds the WHOLE output, so there is no per-splice original to key:
115
- * the pre-splice text lives in the reveal sidecar (lib/reveal.mjs) whose exact
116
- * path the sanitize-time warning named. Not round-trippable — the advisory
117
- * points at the sidecar instead of a span file.
118
- */
119
- export const UNPARSEABLE_MARKER: "[HTML unparseable \u2014 withheld]";
120
97
  export const REHYDRATED_TOOLS: Set<string>;
121
98
  /**
122
99
  * One found token: the exact placeholder text and the dotted field path of the
@@ -126,3 +103,6 @@ export type FoundPlaceholder = {
126
103
  token: string;
127
104
  path: string;
128
105
  };
106
+ import { LAYER2_PLACEHOLDER_RE } from "../../src/layer2-placeholder.mjs";
107
+ import { UNPARSEABLE_PLACEHOLDER } from "../../src/layer2-placeholder.mjs";
108
+ export { LAYER2_PLACEHOLDER_RE, UNPARSEABLE_PLACEHOLDER as UNPARSEABLE_MARKER };