agent-sanitizer 2.19.2 → 2.19.4
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 +5 -0
- package/THREAT-MODEL.md +24 -8
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/output.mjs +28 -11
- package/src/prompt.mjs +9 -6
- package/src/rehydrate.mjs +13 -20
- package/src/view-map.mjs +59 -22
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
- package/types/output.d.mts +15 -2
- package/types/view-map.d.mts +47 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declare a hook's failure posture. Called at module scope by each hook, so
|
|
3
|
+
* importing the hook is what makes its posture reachable.
|
|
4
|
+
* @param {string} hook a member of {@link FAULT_POLICY_HOOKS}
|
|
5
|
+
* @param {FaultPolicy} policy
|
|
6
|
+
* @returns {void}
|
|
7
|
+
*/
|
|
8
|
+
export function registerFaultPolicy(hook: string, policy: FaultPolicy): void;
|
|
9
|
+
/**
|
|
10
|
+
* The registered policy for `hook`. Throws rather than defaulting: a hook whose
|
|
11
|
+
* posture nobody declared has no defensible default — guessing OPEN would let
|
|
12
|
+
* the guarded action through on a hook an operator believed was strict, and
|
|
13
|
+
* guessing CLOSED would block a session on a wiring bug.
|
|
14
|
+
* @param {string} hook
|
|
15
|
+
* @returns {FaultPolicy}
|
|
16
|
+
*/
|
|
17
|
+
export function faultPolicy(hook: string): FaultPolicy;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve `hook`'s response to its own failure under the caller's posture. This
|
|
20
|
+
* is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
|
|
21
|
+
* knob cannot be honored in three hooks and skipped in the fourth.
|
|
22
|
+
* @param {string} hook
|
|
23
|
+
* @param {unknown} err
|
|
24
|
+
* @param {Record<string, any> & {
|
|
25
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
26
|
+
* }} [ctx] hook-specific inputs threaded to the builders (a message table, the
|
|
27
|
+
* parsed input, a remedy). `env` selects the posture; it is threaded to the
|
|
28
|
+
* builders along with the rest, though none reads it — the posture is resolved
|
|
29
|
+
* here precisely so a builder never has to.
|
|
30
|
+
* @returns {FaultOutcome}
|
|
31
|
+
*/
|
|
32
|
+
export function hookFaultOutcome(hook: string, err: unknown, ctx?: Record<string, any> & {
|
|
33
|
+
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
34
|
+
}): FaultOutcome;
|
|
35
|
+
/**
|
|
36
|
+
* Render an outcome's stdout/stderr halves and return its exit code. The caller
|
|
37
|
+
* decides what to do with the code (a hook that must keep running ignores it),
|
|
38
|
+
* and performs `armAlert` itself — persisting the alert needs the hook's own
|
|
39
|
+
* report text, which this module has no view of.
|
|
40
|
+
* @param {FaultOutcome} outcome
|
|
41
|
+
* @param {(chunk: string) => void} [write]
|
|
42
|
+
* @param {(chunk: string) => void} [writeErr]
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
export function writeFaultOutcome(outcome: FaultOutcome, write?: (chunk: string) => void, writeErr?: (chunk: string) => void): number;
|
|
46
|
+
/**
|
|
47
|
+
* The hook modules that must register a fault policy — every CLI entry point in
|
|
48
|
+
* `claude-hooks/*.mjs`. Declared here rather than discovered so registering an
|
|
49
|
+
* unknown name is an error instead of a typo nobody notices.
|
|
50
|
+
* @type {readonly string[]}
|
|
51
|
+
*/
|
|
52
|
+
export const FAULT_POLICY_HOOKS: readonly string[];
|
|
53
|
+
/**
|
|
54
|
+
* What a hook's fault renders to. `fields`/`envelope` are the stdout response
|
|
55
|
+
* (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
|
|
56
|
+
* `envelope` is a hook that answers with a top-level shape instead, like
|
|
57
|
+
* UserPromptSubmit's `{decision:"block"}`); `stderr` and `exitCode` are the
|
|
58
|
+
* process-level halves a hook with no stdout channel uses; `armAlert` asks the
|
|
59
|
+
* caller to persist its cross-hook alert so a LATER gate carries the closed
|
|
60
|
+
* posture the faulting hook could not express itself.
|
|
61
|
+
*/
|
|
62
|
+
export type FaultOutcome = {
|
|
63
|
+
posture: "open" | "closed";
|
|
64
|
+
fields: Record<string, unknown> | null;
|
|
65
|
+
fallbackFields: Record<string, unknown> | null;
|
|
66
|
+
envelope: object | null;
|
|
67
|
+
stderr: string | null;
|
|
68
|
+
exitCode: number;
|
|
69
|
+
armAlert: boolean;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* What a policy's `open`/`closed` builder returns: any subset of a
|
|
73
|
+
* {@link FaultOutcome}'s renderable slots. Everything omitted takes its default
|
|
74
|
+
* (no output, exit 0, no alert).
|
|
75
|
+
*/
|
|
76
|
+
export type FaultParts = {
|
|
77
|
+
fields?: Record<string, unknown>;
|
|
78
|
+
fallbackFields?: Record<string, unknown>;
|
|
79
|
+
envelope?: object;
|
|
80
|
+
stderr?: string;
|
|
81
|
+
exitCode?: number;
|
|
82
|
+
armAlert?: boolean;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* The context a builder is handed: the caller's own inputs (whatever it passed
|
|
86
|
+
* to {@link hookFaultOutcome}) plus the three values every hook derived by hand
|
|
87
|
+
* before — the hook name, the scrubbed error message, and the model-facing
|
|
88
|
+
* open-posture warning.
|
|
89
|
+
*/
|
|
90
|
+
export type FaultContext = Record<string, any> & {
|
|
91
|
+
hook: string;
|
|
92
|
+
err: unknown;
|
|
93
|
+
message: string;
|
|
94
|
+
openContext: string;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* A hook's declared posture.
|
|
98
|
+
*/
|
|
99
|
+
export type FaultPolicy = {
|
|
100
|
+
event: string | null;
|
|
101
|
+
guarded: string;
|
|
102
|
+
open?: (ctx: FaultContext) => FaultParts;
|
|
103
|
+
closed: (ctx: FaultContext) => FaultParts;
|
|
104
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `layers` places an erasing layer after a skip-based one — the ordering
|
|
3
|
+
* whose soundness needs a fixed point. Exported so the property is assertable
|
|
4
|
+
* about a table directly, not only through a run.
|
|
5
|
+
* @param {Layer[]} layers
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function needsFixedPoint(layers: Layer[]): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Run a declared layer chain over one tool input.
|
|
11
|
+
*
|
|
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.
|
|
16
|
+
* @param {string} tool
|
|
17
|
+
* @param {any} toolInput
|
|
18
|
+
* @param {Layer[]} layers
|
|
19
|
+
* @returns {Promise<{ updatedInput: any, changed: boolean, contexts: string[], deny?: string }>}
|
|
20
|
+
*/
|
|
21
|
+
export function runLayerPipeline(tool: string, toolInput: any, layers: Layer[]): Promise<{
|
|
22
|
+
updatedInput: any;
|
|
23
|
+
changed: boolean;
|
|
24
|
+
contexts: string[];
|
|
25
|
+
deny?: string;
|
|
26
|
+
}>;
|
|
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
|
+
/**
|
|
61
|
+
* 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.
|
|
63
|
+
* @typedef {{ updatedInput: any, context: string } | { deny: string } | null} LayerResult
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* One declared layer.
|
|
67
|
+
* - `erases` — may REMOVE code points from a field another layer reads. This is
|
|
68
|
+
* the property that can invalidate a skip-based decision taken earlier.
|
|
69
|
+
* - `skipBased` — its decision can be suppressed by code points present at the
|
|
70
|
+
* time it ran. Such a layer must be re-run after any erasure.
|
|
71
|
+
* - `terminal` — runs once after the fixed point and is never re-run.
|
|
72
|
+
* @typedef {{
|
|
73
|
+
* name: string,
|
|
74
|
+
* erases: boolean,
|
|
75
|
+
* skipBased: boolean,
|
|
76
|
+
* terminal?: boolean,
|
|
77
|
+
* run: (tool: string, input: any) => LayerResult | Promise<LayerResult>,
|
|
78
|
+
* }} Layer
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* 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).
|
|
87
|
+
*/
|
|
88
|
+
export const MAX_PIPELINE_PASSES: 8;
|
|
89
|
+
/**
|
|
90
|
+
* 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.
|
|
92
|
+
*/
|
|
93
|
+
export type LayerResult = {
|
|
94
|
+
updatedInput: any;
|
|
95
|
+
context: string;
|
|
96
|
+
} | {
|
|
97
|
+
deny: string;
|
|
98
|
+
} | null;
|
|
99
|
+
/**
|
|
100
|
+
* One declared layer.
|
|
101
|
+
* - `erases` — may REMOVE code points from a field another layer reads. This is
|
|
102
|
+
* the property that can invalidate a skip-based decision taken earlier.
|
|
103
|
+
* - `skipBased` — its decision can be suppressed by code points present at the
|
|
104
|
+
* time it ran. Such a layer must be re-run after any erasure.
|
|
105
|
+
* - `terminal` — runs once after the fixed point and is never re-run.
|
|
106
|
+
*/
|
|
107
|
+
export type Layer = {
|
|
108
|
+
name: string;
|
|
109
|
+
erases: boolean;
|
|
110
|
+
skipBased: boolean;
|
|
111
|
+
terminal?: boolean;
|
|
112
|
+
run: (tool: string, input: any) => LayerResult | Promise<LayerResult>;
|
|
113
|
+
};
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared layer chain, layers 2-4. Every entry states the two properties
|
|
3
|
+
* the driver reasons about — whether it ERASES code points another layer reads,
|
|
4
|
+
* and whether its own decision is SKIP-BASED and therefore invalidated by such
|
|
5
|
+
* an erasure — so the confusable fold's ordering precondition is enforced by the
|
|
6
|
+
* table instead of restated as a comment.
|
|
7
|
+
*
|
|
8
|
+
* Layer 3 is dropped from the table (not merely skipped at run time) when
|
|
9
|
+
* AGENT_SANITIZER_OUTPUT_DISABLED=1, so the driver sees the chain that will
|
|
10
|
+
* actually run: with no erasing layer left after the fold there is no fixed
|
|
11
|
+
* point to reach and no extra pass to pay for.
|
|
12
|
+
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} rehydrate
|
|
13
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
14
|
+
* @returns {import("./lib/layer-pipeline.mjs").Layer[]}
|
|
15
|
+
*/
|
|
16
|
+
export function preToolUseLayers(rehydrate: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, env?: NodeJS.ProcessEnv | Record<string, string | undefined>): import("./lib/layer-pipeline.mjs").Layer[];
|
|
1
17
|
/**
|
|
2
18
|
* Compose the four protections. Returns the `hookSpecificOutput` fields to
|
|
3
19
|
* emit, or null for a clean no-op. Throws only if a layer's engine throws; the
|
|
@@ -1,16 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan every instruction file under the project, ACCOUNTING for every target
|
|
3
|
+
* the finder returned: `scanned + skipped.length === targets.length`, always.
|
|
4
|
+
*
|
|
5
|
+
* The accounting is the point. This scan is the only thing standing between a
|
|
6
|
+
* poisoned `CLAUDE.md` and a session that loads it as instructions, and its
|
|
7
|
+
* caller announces "clean" on the trace channel — the channel that exists so a
|
|
8
|
+
* MISSING announcement is loud. A per-file failure swallowed into an empty
|
|
9
|
+
* findings list turns "we could not read this file" into "this file is fine",
|
|
10
|
+
* which is the one lie this hook must never tell. So a file that cannot be read
|
|
11
|
+
* is REPORTED as unscanned, not dropped.
|
|
12
|
+
*
|
|
13
|
+
* ANY errno is a skip; only a non-filesystem throw propagates. The split is
|
|
14
|
+
* between "this file could not be read" (report it and keep scanning) and "this
|
|
15
|
+
* code is broken" (a TypeError from an unloaded binding — nothing here can be
|
|
16
|
+
* trusted, so it goes to the caller's declared failure posture). Catching only
|
|
17
|
+
* ENOENT would invert the enforcement: one EACCES target would discard the
|
|
18
|
+
* result for EVERY other instruction file, leaving them unscanned and
|
|
19
|
+
* un-auto-cleaned, and under the shipped OPEN posture the hook fault arms
|
|
20
|
+
* nothing — so the SUSPICIOUS failure would get weaker enforcement than the
|
|
21
|
+
* benign glob race, which reaches `partial` and arms the gate. Same errno-vs-bug
|
|
22
|
+
* split {@link autoCleanFindings} uses.
|
|
23
|
+
* @param {string} [dir] project root to scan (injectable for tests)
|
|
24
|
+
* @returns {{
|
|
25
|
+
* targets: string[],
|
|
26
|
+
* scanned: number,
|
|
27
|
+
* findings: Array<{file: string, findings: ReturnType<typeof scanFile>}>,
|
|
28
|
+
* skipped: Array<{file: string, reason: string}>,
|
|
29
|
+
* }}
|
|
30
|
+
*/
|
|
31
|
+
export function scanProject(dir?: string): {
|
|
32
|
+
targets: string[];
|
|
33
|
+
scanned: number;
|
|
34
|
+
findings: Array<{
|
|
35
|
+
file: string;
|
|
36
|
+
findings: ReturnType<typeof scanFile>;
|
|
37
|
+
}>;
|
|
38
|
+
skipped: Array<{
|
|
39
|
+
file: string;
|
|
40
|
+
reason: string;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The report for targets the scan could not read. Rendered into the alert the
|
|
45
|
+
* PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
|
|
46
|
+
* checkpoint rather than as silence.
|
|
47
|
+
* @param {Array<{file: string, reason: string}>} skipped
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function formatSkipped(skipped: Array<{
|
|
51
|
+
file: string;
|
|
52
|
+
reason: string;
|
|
53
|
+
}>): string;
|
|
1
54
|
/**
|
|
2
55
|
* The hook's CLI: scan the instruction files, auto-clean what it can, persist
|
|
3
56
|
* the alert for the PreToolUse gate otherwise. Exported so a bundle entry
|
|
4
57
|
* (which must claim the CLI slot before this module loads) can run the exact
|
|
5
58
|
* same scan instead of duplicating it.
|
|
6
|
-
* @param {{
|
|
7
|
-
*
|
|
8
|
-
*
|
|
59
|
+
* @param {{
|
|
60
|
+
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
61
|
+
* scan?: () => ReturnType<typeof scanProject>,
|
|
62
|
+
* }} [opts] `trace` is where this scan announces engagement; a host with its
|
|
63
|
+
* own trace channel passes its sink so the announcement lands where its
|
|
64
|
+
* detector reads (see lib/trace.mjs). `scan` is the scanner, injectable so the
|
|
65
|
+
* FAULT path below — a scanner that throws something other than an errno, i.e.
|
|
66
|
+
* a bug — is drivable end to end; no filesystem state can force it, and an
|
|
67
|
+
* untested fault path is how a posture goes missing in the first place.
|
|
9
68
|
* @returns {Promise<void>}
|
|
10
69
|
*/
|
|
11
|
-
export function cliMain({ trace: sink }?: {
|
|
70
|
+
export function cliMain({ trace: sink, scan: runScan }?: {
|
|
12
71
|
trace?: import("./lib/trace.mjs").TraceFn;
|
|
72
|
+
scan?: () => ReturnType<typeof scanProject>;
|
|
13
73
|
}): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} filePath
|
|
76
|
+
* @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
|
|
77
|
+
*/
|
|
78
|
+
export function scanFile(filePath: string): Array<{
|
|
79
|
+
line: number;
|
|
80
|
+
charCount: number;
|
|
81
|
+
method: string;
|
|
82
|
+
decoded: string;
|
|
83
|
+
}>;
|
|
14
84
|
/**
|
|
15
85
|
* @param {string} run
|
|
16
86
|
* @returns {{ method: string, decoded: string }}
|
|
@@ -39,16 +109,6 @@ export function findMdFiles(dir: string): string[];
|
|
|
39
109
|
* @returns {string[]}
|
|
40
110
|
*/
|
|
41
111
|
export function findInstructionFiles(dir: string): string[];
|
|
42
|
-
/**
|
|
43
|
-
* @param {string} filePath
|
|
44
|
-
* @returns {Array<{ line: number, charCount: number, method: string, decoded: string }>}
|
|
45
|
-
*/
|
|
46
|
-
export function scanFile(filePath: string): Array<{
|
|
47
|
-
line: number;
|
|
48
|
-
charCount: number;
|
|
49
|
-
method: string;
|
|
50
|
-
decoded: string;
|
|
51
|
-
}>;
|
|
52
112
|
import { ALERT_FILE } from "./lib/invisible-alert.mjs";
|
|
53
113
|
import { ALERT_ACK_FILE } from "./lib/invisible-alert.mjs";
|
|
54
114
|
export let LONG_RUN_RE: RegExp;
|
package/types/confusables.d.mts
CHANGED
|
@@ -113,8 +113,12 @@ export function normalizeConfusables(tool: string, toolInput: any, { scan, field
|
|
|
113
113
|
*
|
|
114
114
|
* ORDERING: the soundness argument assumes no later layer erases code points
|
|
115
115
|
* from the same field, which would let an unmapped glyph the gate relied on
|
|
116
|
-
* disappear after the decision
|
|
117
|
-
*
|
|
116
|
+
* disappear after the decision — a zero-width run padded into a token suppresses
|
|
117
|
+
* the fold, and the erasing layer then removes the very evidence for skipping it.
|
|
118
|
+
* This fold does NOT run last: on Bash.command the invisible-char strip follows
|
|
119
|
+
* it. A caller that composes the two is therefore responsible for re-running
|
|
120
|
+
* this fold on the post-erasure text until it reports nothing, which is what the
|
|
121
|
+
* hook driver in claude-hooks/lib/layer-pipeline.mjs does.
|
|
118
122
|
*
|
|
119
123
|
* Genuine non-confusable non-ASCII (accented Latin, CJK, emoji) is untouched
|
|
120
124
|
* regardless, since a faithful scanner does not flag it.
|
package/types/invisible.d.mts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* True when every ANSI control introducer in `text` belongs to a display-only
|
|
3
|
-
* SGR color sequence (so stripping the ANSI
|
|
3
|
+
* SGR color sequence (so stripping the ANSI removes only cosmetic styling,
|
|
4
4
|
* nothing that could move the cursor, erase, or carry a payload). Recognizes
|
|
5
5
|
* both the 7-bit `ESC[…m` and 8-bit C1 (`U+009B…m`) SGR encodings.
|
|
6
|
+
*
|
|
7
|
+
* Answered by the STRIPPER'S OWN tokenizer: scanAnsi emits one token per raw
|
|
8
|
+
* introducer — 7-bit ESC and the whole C1 block, so a C1 cursor-move
|
|
9
|
+
* (`U+009B 2J`), a C1-OSC string (`U+009D … BEL`), a C1-DCS/APC payload and a
|
|
10
|
+
* lone or partial escape each yield a non-SGR token — and the predicate is
|
|
11
|
+
* "every token is SGR". Because the same scan decides what Layer 1 splices,
|
|
12
|
+
* this can no longer report "colour only" for bytes the stripper leaves behind.
|
|
6
13
|
* @param {string} text
|
|
7
14
|
* @returns {boolean}
|
|
8
15
|
*/
|
|
@@ -84,7 +91,7 @@ export const CATEGORY_LABELS: Readonly<Record<string, string>>;
|
|
|
84
91
|
/** @type {Array<[string, RegExp]>} Each entry pairs a CATEGORY code with its detector. */
|
|
85
92
|
export const CHECKS: Array<[string, RegExp]>;
|
|
86
93
|
export const STRIP: RegExp;
|
|
87
|
-
export
|
|
94
|
+
export { SGR_RE } from "./ansi.mjs";
|
|
88
95
|
export const LONG_RUN_THRESHOLD: 10;
|
|
89
96
|
/** Total invisible-char count above which a file/prompt is treated as
|
|
90
97
|
* payload-capable even without a long run (threshold-evasion catch). */
|
|
@@ -96,3 +103,5 @@ export const TOTAL_PRESERVED_JOINER_BUDGET: 16;
|
|
|
96
103
|
export const PRESERVED_JOINER_PER_VISIBLE: 8;
|
|
97
104
|
export const PRESERVE_HARD_CAP: 64;
|
|
98
105
|
export const LINGUISTIC_SCRIPTS: string[];
|
|
106
|
+
/** @type {ReadonlyArray<readonly [string, number, number]>} */
|
|
107
|
+
export const BRAHMIC_CONSONANT_RANGES: ReadonlyArray<readonly [string, number, number]>;
|
package/types/layer1.d.mts
CHANGED
|
@@ -19,16 +19,25 @@ export function stripAnsiFully(input: string): string;
|
|
|
19
19
|
* raw ANSI control introducer (7-bit ESC U+001B and the whole 8-bit C1 control
|
|
20
20
|
* block U+0080–U+009F: CSI, the DCS/SOS/OSC/PM/APC string introducers, and ST).
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
22
|
+
* The two passes FEED each other in both directions, so neither ordering is
|
|
23
|
+
* enough on its own and the composition is iterated to a fixed point instead:
|
|
24
|
+
* removing an invisible char reconstitutes an escape its split hid
|
|
25
|
+
* (`ESC`<ZWSP>`[32m` → `ESC[32m`), and removing an escape makes two invisibles
|
|
26
|
+
* ADJACENT that were not (`م ZWJ ESC[m ZWJ م` → a joiner run the invisible pass
|
|
27
|
+
* classifies as a payload channel rather than linguistic). A pipeline with a
|
|
28
|
+
* fixed number of alternations always leaves one of those unanswered — this used
|
|
29
|
+
* to re-strip ANSI after the invisible pass and stop, so `applyLayer1` was not
|
|
30
|
+
* idempotent and the rehydrator's "re-cleaning reproduces the view" assumption
|
|
31
|
+
* did not hold.
|
|
32
|
+
*
|
|
33
|
+
* The residual sweep runs only once the composition is STABLE, because sweeping
|
|
34
|
+
* an introducer early destroys the sequence a later ANSI pass would have removed
|
|
35
|
+
* whole, promoting a hidden control to visible text. A final UNCONDITIONAL sweep
|
|
36
|
+
* follows the loop so the no-raw-introducer guarantee does not depend on the
|
|
37
|
+
* pass bound.
|
|
38
|
+
*
|
|
39
|
+
* `deAnsi` is the ANSI strip of the ORIGINAL text (invisible runs intact), the
|
|
40
|
+
* scope a LONG_RUN payload check needs — not an intermediate of the loop.
|
|
32
41
|
* @param {string} text
|
|
33
42
|
* @returns {{ cleaned: string, deAnsi: string, found: string[] }}
|
|
34
43
|
*/
|
package/types/output.d.mts
CHANGED
|
@@ -26,8 +26,21 @@ export function describeWarned(warned: {
|
|
|
26
26
|
/**
|
|
27
27
|
* Delete each verbatim span in `spans` from `text`. The secure Layer-5
|
|
28
28
|
* primitive: a filter can only ask for deletions, so this can never inject
|
|
29
|
-
* bytes. Returns the new text and how many
|
|
30
|
-
*
|
|
29
|
+
* bytes. Returns the new text and how many span occurrences were removed (0
|
|
30
|
+
* when no span was present).
|
|
31
|
+
*
|
|
32
|
+
* Every occurrence is located in the ORIGINAL `text` and the deletions applied
|
|
33
|
+
* in one ordered pass ({@link spliceOrdered}), so every removed byte lies inside
|
|
34
|
+
* a match some span had in the INPUT. Deleting span-by-span with a
|
|
35
|
+
* chained `split`/`join` would not hold that line: an earlier deletion joins the
|
|
36
|
+
* bytes on either side of it and can CREATE a match for a later span that never
|
|
37
|
+
* occurred in the input — `deleteVerbatimSpans("PRE-XX-POST", ["-XX-", "PREPOST"])`
|
|
38
|
+
* then deletes the whole document. That would widen the Layer-5 seam's blast
|
|
39
|
+
* radius (see the module doc) from "a compromised filter can at most remove the
|
|
40
|
+
* content it named" to "it can remove content it never named".
|
|
41
|
+
*
|
|
42
|
+
* Overlapping spans are resolved first-match-wins, so `removed` counts the
|
|
43
|
+
* occurrences actually spliced out, never a double-count of the same bytes.
|
|
31
44
|
* @param {string} text
|
|
32
45
|
* @param {string[]} spans
|
|
33
46
|
* @returns {{ text: string, removed: number }}
|
package/types/view-map.d.mts
CHANGED
|
@@ -106,9 +106,15 @@ export function resolveSpan(content: string, cleaned: string, view: {
|
|
|
106
106
|
}[];
|
|
107
107
|
} | null;
|
|
108
108
|
/**
|
|
109
|
-
* All occurrences of any needle in `text`, ordered by position.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
* All occurrences of any needle in `text`, ordered by position. Every index is
|
|
110
|
+
* computed against the ORIGINAL `text`, so the caller can splice them in one
|
|
111
|
+
* pass ({@link spliceOrdered}). Redaction placeholder texts never
|
|
112
|
+
* substring-overlap one another (each ends in "]" right after its
|
|
113
|
+
* distinguishing label), so for that caller the sorted matches are also
|
|
114
|
+
* non-overlapping; needles from an untrusted source (a Layer-5 filter's
|
|
115
|
+
* removeSpans) can overlap, which spliceOrdered resolves first-match-wins.
|
|
116
|
+
* Distinct needles matching at the SAME index keep `needles` order (Array#sort
|
|
117
|
+
* is stable), so first-match-wins is deterministic.
|
|
112
118
|
* @param {string} text
|
|
113
119
|
* @param {string[]} needles
|
|
114
120
|
* @returns {{text: string, index: number}[]}
|
|
@@ -117,6 +123,44 @@ export function orderedMatches(text: string, needles: string[]): {
|
|
|
117
123
|
text: string;
|
|
118
124
|
index: number;
|
|
119
125
|
}[];
|
|
126
|
+
/**
|
|
127
|
+
* Replace every match in `matches` with `replacementFor(match, i)` in a SINGLE
|
|
128
|
+
* ordered pass over `text`. THE splice primitive for this codebase — the sole
|
|
129
|
+
* sound way to substitute several needles at once.
|
|
130
|
+
*
|
|
131
|
+
* A chained `text.split(needle).join(value)` per needle is unsound in both
|
|
132
|
+
* directions, which is why no caller may hand-roll one:
|
|
133
|
+
* - substitution: an inserted value whose bytes contain a LATER needle is
|
|
134
|
+
* re-matched by the next split and corrupted (or partially exposed);
|
|
135
|
+
* - deletion: an earlier deletion joins the bytes on either side of it and
|
|
136
|
+
* can CREATE a later needle's match, deleting text that needle never
|
|
137
|
+
* matched in the input ("PRE-XX-POST" minus "-XX-" yields "PREPOST").
|
|
138
|
+
* Because every index in `matches` is measured against the original `text`,
|
|
139
|
+
* this pass only ever touches bytes the caller actually matched.
|
|
140
|
+
*
|
|
141
|
+
* Overlapping matches are resolved first-match-wins: a match starting before
|
|
142
|
+
* the previous one ended is skipped, never spliced at a shifted offset.
|
|
143
|
+
* `i` is the match's index in `matches` (stable across skips) so a caller
|
|
144
|
+
* pairing matches positionally with its own array stays aligned.
|
|
145
|
+
* @param {string} text
|
|
146
|
+
* @param {{text: string, index: number}[]} matches ordered by index, indices into `text`
|
|
147
|
+
* @param {(match: {text: string, index: number}, i: number) => string} replacementFor
|
|
148
|
+
* @returns {{text: string, spans: {start: number, end: number}[]}} spliced text
|
|
149
|
+
* and the [start, end) range each replacement occupies in it
|
|
150
|
+
*/
|
|
151
|
+
export function spliceOrdered(text: string, matches: {
|
|
152
|
+
text: string;
|
|
153
|
+
index: number;
|
|
154
|
+
}[], replacementFor: (match: {
|
|
155
|
+
text: string;
|
|
156
|
+
index: number;
|
|
157
|
+
}, i: number) => string): {
|
|
158
|
+
text: string;
|
|
159
|
+
spans: {
|
|
160
|
+
start: number;
|
|
161
|
+
end: number;
|
|
162
|
+
}[];
|
|
163
|
+
};
|
|
120
164
|
/**
|
|
121
165
|
* On-disk [start, end) span of every redaction pair, mapped from its view
|
|
122
166
|
* offset through placeholder expansion (view → cleaned) and stripped invisible
|