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.
@@ -23,9 +23,12 @@ export function sweepStaleReveals(): void;
23
23
  * can precompute it and pre-plant a symlink there to redirect this write onto a
24
24
  * victim file — writeFileNoFollow refuses that instead of following it.
25
25
  * @param {string} content
26
+ * @param {boolean} [secretsRedacted] whether the caller redacted `content`
27
+ * before this call; only then does the hint claim it (with the secret layer
28
+ * off the bytes are stored verbatim)
26
29
  * @returns {string | null}
27
30
  */
28
- export function persistReveal(content: string): string | null;
31
+ export function persistReveal(content: string, secretsRedacted?: boolean): string | null;
29
32
  /**
30
33
  * The store path for one Layer-2 splice's original bytes, keyed by the
31
34
  * placeholder key. Throws on a malformed key (fail loud: every caller extracts
@@ -42,7 +45,14 @@ export function spanPath(key: string): string;
42
45
  * symlink-refusingly (O_EXCL) because the path is content-addressed — an
43
46
  * attacker who chose the page bytes can precompute it and pre-plant a symlink.
44
47
  * Content-addressed dedupe: an existing entry (same key = same raw original)
45
- * is left in place and counts as success. Returns true when the span is on
48
+ * is left in place and counts as success — EXCEPT when `secretsRedacted` is
49
+ * set and the entry differs from `content`. The key is minted from the RAW
50
+ * original, so a span first stored with the secret opt-in off holds the raw
51
+ * bytes under the very key a later opted-in run computes; keeping it would
52
+ * restore a raw secret into a write whose notice says secrets were redacted
53
+ * before storage. The opted-in write therefore replaces a differing entry. The
54
+ * reverse (opt-in off meeting a redacted entry) keeps the entry: redacted is
55
+ * the safe direction. Returns true when the span is on
46
56
  * disk (written now or already there); false on any failure — non-fatal by
47
57
  * contract, exactly like a failed reveal write (the splice already protected
48
58
  * the output; a later rehydration of this key fails CLOSED with a deny).
@@ -53,9 +63,11 @@ export function spanPath(key: string): string;
53
63
  * NAME, not an integrity check.
54
64
  * @param {string} key
55
65
  * @param {string} content the splice's original, redacted BEFORE this call
66
+ * @param {boolean} [secretsRedacted] whether `content` went through the secret
67
+ * redactor (the run's opt-in); only then may it replace a differing entry
56
68
  * @returns {boolean}
57
69
  */
58
- export function persistSpan(key: string, content: string): boolean;
70
+ export function persistSpan(key: string, content: string, secretsRedacted?: boolean): boolean;
59
71
  /**
60
72
  * The stored original for one Layer-2 placeholder key, or null when no span is
61
73
  * stored (or the store is unusable). The open refuses symlinks (O_NOFOLLOW):
@@ -30,11 +30,15 @@ export function confirmMarkerPath(fingerprint: string): string;
30
30
  */
31
31
  export function sweepStaleConfirms(): void;
32
32
  /**
33
- * Whether git tracks `filePath`. Exit 0 is tracked; exit 1 (untracked) and 128
34
- * (not a repository) both mean "no git recovery exists", which is what the
35
- * guard actually cares about. A spawn-level failure (no git binary) means we
36
- * cannot tell — report tracked so the guard skips (fail open) rather than
37
- * denying on missing tooling.
33
+ * Whether git tracks `filePath`, with every probe failure on the skip side the
34
+ * module doc declares. Exit 0 is tracked and exit 1 untracked. Exit 128 is
35
+ * either "not a repository" — no git recovery exists, so the guard applies — or
36
+ * a repository git could not read (a corrupt index, a permission error), which
37
+ * says nothing about the file; a second probe asks git which, reading its
38
+ * printed answer (`false` inside a bare repository or a `.git` directory is
39
+ * also exit 0, and also has no recovery path). A spawn-level
40
+ * failure (no git binary) or any other status also means we cannot tell —
41
+ * report tracked so the guard skips rather than denying on broken tooling.
38
42
  * @param {string} filePath
39
43
  * @param {typeof spawnSync} [spawn]
40
44
  * @returns {boolean}
@@ -4,11 +4,12 @@
4
4
  * removed #<key>]` placeholders (copied from sanitized tool output) has each
5
5
  * one restored to the stored original bytes from the reveal store's span files.
6
6
  *
7
- * SECURITY invariant: the stored span content was REDACTED before persistence
8
- * (sanitize-output runs strict web-ingress redaction on each splice original
9
- * before persistSpan), so this rehydration can never write a raw secret — the
10
- * worst it can restore is `[REDACTED…]` placeholder text standing where the
11
- * secret was, which the on-disk tripwire then flags on later Reads.
7
+ * SECURITY invariant, with the secret layer on: the stored span content was
8
+ * REDACTED before persistence (sanitize-output runs strict web-ingress
9
+ * redaction on each splice original before persistSpan), so this rehydration
10
+ * can never write a raw secret — the worst it can restore is `[REDACTED…]`
11
+ * placeholder text, which the on-disk tripwire then flags on later Reads. With
12
+ * the layer off, spans are stored verbatim and the context claims nothing.
12
13
  *
13
14
  * Composition with the secret rehydrator: this runs AFTER it (a later terminal
14
15
  * layer), and the two grammars are disjoint — a Layer-2 placeholder never
@@ -25,51 +26,62 @@
25
26
  * edits / notebook JSON cannot be rehydrated).
26
27
  * @param {string} tool
27
28
  * @param {any} toolInput
29
+ * @param {boolean} [secretsOn] the run's secret opt-in (decided once by
30
+ * buildPreToolUseResponse; a direct caller reads the env); only with it on
31
+ * does the context claim the restored bytes were redacted
28
32
  * @returns {{ updatedInput: any, context: string } | { deny: string } | null}
29
33
  */
30
- export function rehydrateLayer2(tool: string, toolInput: any): {
34
+ export function rehydrateLayer2(tool: string, toolInput: any, secretsOn?: boolean): {
31
35
  updatedInput: any;
32
36
  context: string;
33
37
  } | {
34
38
  deny: string;
35
39
  } | null;
36
40
  /**
37
- * The declared layer chain, layers 2-4. Every entry states the two properties
41
+ * The declared stage chain after the gate. Every entry states the two properties
38
42
  * the driver reasons about — whether it ERASES code points another layer reads,
39
43
  * and whether its own decision is SKIP-BASED and therefore invalidated by such
40
44
  * an erasure — so the confusable fold's ordering precondition is enforced by the
41
45
  * table instead of restated as a comment.
42
46
  *
43
- * Layer 3 is dropped from the table (not merely skipped at run time) when
47
+ * The authored-content stage is dropped from the table (not merely skipped) when
44
48
  * AGENT_SANITIZER_OUTPUT_DISABLED=1, so the driver sees the chain that will
45
49
  * actually run: with no erasing layer left after the fold there is no fixed
46
- * point to reach and no extra pass to pay for.
47
- * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} rehydrate
50
+ * point to reach and no extra pass to pay for. A null `rehydrate` drops the
51
+ * secret rehydrator the same way.
52
+ * @param {((tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>) | null} rehydrate
48
53
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
54
+ * @param {boolean} [secretsOn] the run's secret opt-in (see
55
+ * buildPreToolUseResponse, which decides it once and passes it here)
49
56
  * @returns {import("./lib/layer-pipeline.mjs").Layer[]}
50
57
  */
51
- export function preToolUseLayers(rehydrate: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, env?: NodeJS.ProcessEnv | Record<string, string | undefined>): import("./lib/layer-pipeline.mjs").Layer[];
58
+ export function preToolUseLayers(rehydrate: ((tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>) | null, env?: NodeJS.ProcessEnv | Record<string, string | undefined>, secretsOn?: boolean): import("./lib/layer-pipeline.mjs").Layer[];
52
59
  /**
53
- * Compose the four protections. Returns the `hookSpecificOutput` fields to
54
- * emit, or null for a clean no-op. Throws only if a layer's engine throws; the
55
- * caller fails closed (ask) on any throw. Every exit routes through emitTraced.
60
+ * Compose the five stages. Returns the `hookSpecificOutput` fields to emit, or
61
+ * null for a clean no-op. Throws only if a stage's engine throws; the caller
62
+ * then applies this hook's fault posture (open by default — see
63
+ * hookFailureFields). Every exit routes through emitTraced.
56
64
  * @param {any} input parsed PreToolUse event
57
65
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
58
- * injectable for tests; the default binds the real redactor-daemon io (the
59
- * layer reads the target file and maps secrets through the daemon)
66
+ * injectable for tests, and then run whatever the secret opt-in says; the
67
+ * default binds the real redactor-daemon io (the layer reads the target file
68
+ * and maps secrets through the daemon) and runs only with the opt-in on
60
69
  * @param {import("./lib/trace.mjs").TraceFn} [sink] where engagement is
61
70
  * announced; a host with its own trace channel passes its sink (see lib/trace.mjs)
71
+ * @param {import("./lib/layer-pipeline.mjs").Layer[]} [layers] the stage table,
72
+ * injectable so a test can drive one the shipped stages never produce (an
73
+ * oscillating stage); defaults to {@link preToolUseLayers} for this call
62
74
  * @returns {Promise<Record<string, unknown> | null>}
63
75
  */
64
- export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, sink?: import("./lib/trace.mjs").TraceFn): Promise<Record<string, unknown> | null>;
76
+ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, sink?: import("./lib/trace.mjs").TraceFn, layers?: import("./lib/layer-pipeline.mjs").Layer[]): Promise<Record<string, unknown> | null>;
65
77
  /**
66
- * Agent-agnostic judge over the four protections: consumes a control-plane
78
+ * Agent-agnostic judge over the five stages: consumes a control-plane
67
79
  * ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
68
80
  * sanitization pipeline through its own adapter. The wired Claude CLI below
69
81
  * routes through this judge and renders the Verdict with the Claude adapter; on
70
82
  * any throw (a control-plane package-load failure included) it falls back to
71
- * failClosedFields — a native response that needs no package — so the
72
- * fail-closed posture holds even when the adapter never loaded.
83
+ * hookFailureFields — a native response that needs no package — so the
84
+ * caller's fault posture holds even when the adapter never loaded.
73
85
  * @param {import("agent-control-plane-core").ToolCallEvent} event
74
86
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
75
87
  * @param {{
@@ -135,14 +147,15 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
135
147
  }): Record<string, unknown>;
136
148
  /**
137
149
  * True when a faulting PreToolUse call is the one case the OPEN posture must
138
- * still not pass: a write-shaped tool whose input carries the
139
- * redaction-placeholder prefix. Such a placeholder stands for a secret the
140
- * sanitizer redacted out of the model's view; with the sanitizer down,
141
- * rehydration cannot translate it back, so letting the call through would
142
- * persist the literal placeholder text over the real secret on disk — a
143
- * destructive clobber, not a missed scan. Package-free by construction (a Set
144
- * lookup and a substring test on the already-parsed payload), so it holds when
145
- * the failure IS the missing package.
150
+ * still not pass: a write-shaped tool whose input carries a redaction
151
+ * placeholder, matched by the real grammar (containsPlaceholder) so
152
+ * hint-prefixed prose such as `[REDACTED…]` is not mistaken for one. Such a
153
+ * placeholder stands for a secret the sanitizer redacted out of the model's
154
+ * view; with the sanitizer down, rehydration cannot translate it back, so
155
+ * letting the call through would persist the literal placeholder text over the
156
+ * real secret on disk — a destructive clobber, not a missed scan.
157
+ * Package-free by construction (a Set lookup and a walk of the already-parsed
158
+ * payload), so it holds when the failure IS the missing package.
146
159
  * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
147
160
  * @returns {boolean}
148
161
  */
@@ -7,8 +7,8 @@
7
7
  * never WHETHER their result is applied.
8
8
  *
9
9
  * A callback that throws is not caught here. That is deliberate: the throw lands
10
- * in the CLI's fail-closed catch and the tool output is suppressed, so a broken
11
- * extension cannot degrade into showing unvetted output.
10
+ * in the CLI's catch like any other sanitizer fault, so a broken extension gets
11
+ * exactly the posture the operator chose (emitHookFailure) and no quieter one.
12
12
  *
13
13
  * These are NOT the Layer-5 injection-filter seam and do not inherit its
14
14
  * delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
@@ -58,11 +58,13 @@
58
58
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
59
59
  * all leaves of one hook run; a direct caller gets a fresh full budget
60
60
  * @param {SanitizeExtensions} [ext]
61
+ * @param {boolean} [secretsOn] the secret opt-in, decided ONCE per hook run by
62
+ * {@link evaluateToolOutput} and threaded down; a direct caller reads the env
61
63
  * @returns {Promise<{ cleaned: string, warnings: string[], notes: string[], modified: boolean, sgrNote: boolean, reveal?: string, splices?: Array<{ placeholder: string, original: string }> }>}
62
64
  */
63
65
  export function sanitizeText(text: string, toolName: string, deadline?: {
64
66
  remainingMs: () => number;
65
- }, ext?: SanitizeExtensions): Promise<{
67
+ }, ext?: SanitizeExtensions, secretsOn?: boolean): Promise<{
66
68
  cleaned: string;
67
69
  warnings: string[];
68
70
  notes: string[];
@@ -104,6 +106,7 @@ export function sanitizeText(text: string, toolName: string, deadline?: {
104
106
  * after `notes` for the same positional-compatibility reason
105
107
  * @param {string} [path] dotted location of `value` within the tool output,
106
108
  * used only to name a key collision's location in its warning
109
+ * @param {boolean} [secretsOn] the secret opt-in (see {@link sanitizeText})
107
110
  * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
108
111
  */
109
112
  export function sanitizeValue(value: any, toolName: string, warnings: string[], reveals?: string[], deadline?: {
@@ -111,7 +114,7 @@ export function sanitizeValue(value: any, toolName: string, warnings: string[],
111
114
  }, ext?: SanitizeExtensions, notes?: string[], splices?: Array<{
112
115
  placeholder: string;
113
116
  original: string;
114
- }>, path?: string): Promise<{
117
+ }>, path?: string, secretsOn?: boolean): Promise<{
115
118
  value: any;
116
119
  modified: boolean;
117
120
  sgrNote: boolean;
@@ -235,7 +238,7 @@ export function evaluateToolOutput(input: any, ext?: SanitizeExtensions): Promis
235
238
  * {@link evaluateToolOutput} already returns those contract fields (or null),
236
239
  * so the judge only stamps the `allow` decision onto them — no native-envelope
237
240
  * translation. Throws only if a layer engine throws (or on an UNKNOWN event);
238
- * the CLI fails closed on any throw.
241
+ * the CLI applies this hook's fault posture to any throw (emitHookFailure).
239
242
  * @param {import("agent-control-plane-core").ToolCallEvent} event
240
243
  * @param {SanitizeExtensions} [ext]
241
244
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
@@ -247,9 +250,9 @@ export function judgeSanitizeOutput(event: import("agent-control-plane-core").To
247
250
  * omits the field is a PostToolUse call by construction. The claude adapter
248
251
  * extracts `tool_response` (this hook's actual input) ONLY for a PostToolUse
249
252
  * event; without this default a field-less but legitimate payload would parse as
250
- * UNKNOWN, {@link judgeSanitizeOutput} would throw, and the CLI would fail closed
251
- * (suppress) on real tool output. A payload carrying a DIFFERENT event name is
252
- * left untouched, so the judge's UNKNOWN guard still fails closed on a genuinely
253
+ * UNKNOWN, {@link judgeSanitizeOutput} would throw, and the CLI would take the
254
+ * fault posture on real tool output. A payload carrying a DIFFERENT event name
255
+ * is left untouched, so the judge's UNKNOWN guard still fires on a genuinely
253
256
  * unrecognized event.
254
257
  * @param {unknown} input the raw stdin payload
255
258
  * @returns {unknown}
@@ -285,8 +288,8 @@ export const ON_DISK_PLACEHOLDER_WARNING: string;
285
288
  * never WHETHER their result is applied.
286
289
  *
287
290
  * A callback that throws is not caught here. That is deliberate: the throw lands
288
- * in the CLI's fail-closed catch and the tool output is suppressed, so a broken
289
- * extension cannot degrade into showing unvetted output.
291
+ * in the CLI's catch like any other sanitizer fault, so a broken extension gets
292
+ * exactly the posture the operator chose (emitHookFailure) and no quieter one.
290
293
  *
291
294
  * These are NOT the Layer-5 injection-filter seam and do not inherit its
292
295
  * delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
@@ -2,12 +2,11 @@
2
2
  * Judge a normalized prompt-submit event. Agent-agnostic: consumes the
3
3
  * control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
4
4
  * renders through any agent adapter, not just Claude's. Throws (into the
5
- * calling hook's catch) when the sanitizer package never loaded — this hook is
6
- * the only defense on user input, so a prompt it cannot classify must block,
7
- * never pass through.
5
+ * calling hook's catch) when the sanitizer package never loaded, so a prompt it
6
+ * cannot classify takes the hook's fault posture rather than a silent ALLOW.
8
7
  * @param {import("agent-control-plane-core").ToolCallEvent} event
9
8
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
10
- * to the package's stripAnsiFully; injectable so the fail-closed path is testable)
9
+ * to the package's stripAnsiFully; injectable so the fault path is testable)
11
10
  * @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
12
11
  * merged over the defaults so a partial table can never leave a field unset
13
12
  * @returns {import("agent-control-plane-core").Verdict}
@@ -26,7 +25,7 @@ export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core"
26
25
  * env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
27
26
  * }} [opts]
28
27
  * `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
29
- * injectable so the fail-closed path is testable); `overrides` are reason
28
+ * injectable so the fault path is testable); `overrides` are reason
30
29
  * overrides, merged over the defaults so a partial table can never leave a field
31
30
  * unset; `trace` is where engagement is announced, for a host with its own trace
32
31
  * channel (see lib/trace.mjs); `env` is the failure-posture source (see
package/types/html.d.mts CHANGED
@@ -20,19 +20,6 @@ export function isHiddenOpen(htmlValue: string): string | null;
20
20
  * @returns {string | null}
21
21
  */
22
22
  export function closingTagName(htmlValue: string): string | null;
23
- /**
24
- * The keyed, content-addressed placeholder for one Layer-2 splice:
25
- * `[hidden HTML removed #<key>]` / `[HTML comment removed #<key>]`, where
26
- * `<key>` is the first 12 lowercase-hex chars of sha256 over the UTF-8
27
- * encoding of the ORIGINAL spliced text. Content-addressed on purpose:
28
- * identical spliced content yields the identical placeholder, so a rehydrator
29
- * can match placeholder → original by key alone, and duplicated content never
30
- * produces conflicting keys.
31
- * @param {SpliceKind} kind
32
- * @param {string} original the exact text the splice removed
33
- * @returns {string}
34
- */
35
- export function layer2Placeholder(kind: SpliceKind, original: string): string;
36
23
  /**
37
24
  * Replace each range of `text` with its kind's keyed placeholder, preserving
38
25
  * every byte outside the ranges verbatim. Overlapping/nested ranges are merged
@@ -169,16 +156,8 @@ export function detectConfusableHosts(text: string): Array<{
169
156
  description: string;
170
157
  }> | null;
171
158
  export const REPORTED_TAGS: Set<string>;
172
- /**
173
- * The single grammar definition for keyed Layer-2 placeholders — the exact
174
- * output of {@link layer2Placeholder}, capture group 1 = the key. Global so
175
- * callers can scan a document for every placeholder; reset `lastIndex` (or
176
- * use `matchAll`) between uses.
177
- */
178
- export const LAYER2_PLACEHOLDER_RE: RegExp;
179
159
  export const HIDDEN_PLACEHOLDER: "[hidden HTML removed";
180
160
  export const COMMENT_PLACEHOLDER: "[HTML comment removed";
181
- export const UNPARSEABLE_PLACEHOLDER: "[HTML unparseable \u2014 withheld]";
182
161
  export const DATA_URI_LENGTH_THRESHOLD: 4096;
183
162
  export type CollectedUrl = {
184
163
  url: string;
@@ -213,4 +192,7 @@ import { MD_LINK_HINT } from "./gates.mjs";
213
192
  import { SECRET_HINT } from "./gates.mjs";
214
193
  import { SECRET_HINT_EXT } from "./gates.mjs";
215
194
  import { matchesSecretHint } from "./gates.mjs";
216
- export { HTML_TAG_PRESENT, MD_LINK_HINT, SECRET_HINT, SECRET_HINT_EXT, matchesSecretHint };
195
+ import { layer2Placeholder } from "./layer2-placeholder.mjs";
196
+ import { LAYER2_PLACEHOLDER_RE } from "./layer2-placeholder.mjs";
197
+ import { UNPARSEABLE_PLACEHOLDER } from "./layer2-placeholder.mjs";
198
+ export { HTML_TAG_PRESENT, MD_LINK_HINT, SECRET_HINT, SECRET_HINT_EXT, matchesSecretHint, layer2Placeholder, LAYER2_PLACEHOLDER_RE, UNPARSEABLE_PLACEHOLDER };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The keyed, content-addressed placeholder for one Layer-2 splice:
3
+ * `[hidden HTML removed #<key>]` / `[HTML comment removed #<key>]`, where
4
+ * `<key>` is the first 12 lowercase-hex chars of sha256 over the UTF-8
5
+ * encoding of the ORIGINAL spliced text. Content-addressed on purpose:
6
+ * identical spliced content yields the identical placeholder, so a rehydrator
7
+ * can match placeholder → original by key alone, and duplicated content never
8
+ * produces conflicting keys.
9
+ * @param {"comment" | "hidden"} kind
10
+ * @param {string} original the exact text the splice removed
11
+ * @returns {string}
12
+ */
13
+ export function layer2Placeholder(kind: "comment" | "hidden", original: string): string;
14
+ /**
15
+ * The single grammar definition for keyed Layer-2 placeholders — the exact
16
+ * output of {@link layer2Placeholder}, capture group 1 = the key. Global so
17
+ * callers can scan a document for every placeholder; reset `lastIndex` (or
18
+ * use `matchAll`) between uses.
19
+ */
20
+ export const LAYER2_PLACEHOLDER_RE: RegExp;
21
+ export const UNPARSEABLE_PLACEHOLDER: "[HTML unparseable \u2014 withheld]";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The keyed, content-addressed placeholder for one Layer-2 splice:
3
+ * `[hidden HTML removed #<key>]` / `[HTML comment removed #<key>]`, where
4
+ * `<key>` is the first 12 lowercase-hex chars of sha256 over the UTF-8
5
+ * encoding of the ORIGINAL spliced text. Content-addressed on purpose:
6
+ * identical spliced content yields the identical placeholder, so a rehydrator
7
+ * can match placeholder → original by key alone, and duplicated content never
8
+ * produces conflicting keys.
9
+ * @param {"comment" | "hidden"} kind
10
+ * @param {string} original the exact text the splice removed
11
+ * @returns {string}
12
+ */
13
+ export function layer2Placeholder(kind: "comment" | "hidden", original: string): string;
14
+ /**
15
+ * The single grammar definition for keyed Layer-2 placeholders — the exact
16
+ * output of {@link layer2Placeholder}, capture group 1 = the key. Global so
17
+ * callers can scan a document for every placeholder; reset `lastIndex` (or
18
+ * use `matchAll`) between uses.
19
+ */
20
+ export const LAYER2_PLACEHOLDER_RE: RegExp;
21
+ export const UNPARSEABLE_PLACEHOLDER: "[HTML unparseable \u2014 withheld]";