agent-sanitizer 2.36.0 → 2.37.1
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/THREAT-MODEL.md +1 -1
- package/claude-hooks/scan-loaded-instructions.mjs +40 -34
- package/package.json +1 -1
- package/python/agent_sanitizer/data/invisible-charset.json +3 -2
- package/src/ansi.mjs +97 -10
- package/types/ansi.d.mts +22 -0
- package/types/claude-hooks/scan-loaded-instructions.d.mts +20 -18
package/THREAT-MODEL.md
CHANGED
|
@@ -287,7 +287,7 @@ root's own instruction files, the `CLAUDE.md` chain above it, and the root
|
|
|
287
287
|
`.claude/` context subdirectories — and `scan-loaded-instructions`
|
|
288
288
|
(InstructionsLoaded) scans every other instruction file — including the
|
|
289
289
|
user-global `~/.claude` memory and rules, which load into every session on the
|
|
290
|
-
machine —
|
|
290
|
+
machine — reading the one path the event names, at the moment it loads. The second cannot block: the file is already in
|
|
291
291
|
context when it fires, so its neutralization is to strip the payload from disk
|
|
292
292
|
(so no reload re-reads it) and tell the model to treat what it just read as
|
|
293
293
|
untrusted data. Auto-cleaning is confined to `CLAUDE_PROJECT_DIR` in both — an
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* This is the lazy half of the instruction-file scan. Claude Code loads a
|
|
6
6
|
* subdirectory's CLAUDE.md (and a path-scoped rule, an `@import`, a
|
|
7
|
-
* post-compaction reload) only when it needs it, and this event
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* post-compaction reload) only when it needs it, and this event names the file
|
|
8
|
+
* it just loaded — so the scan costs one read and one `scanText` on exactly the
|
|
9
|
+
* file that entered context, with no glob and no walk. The SessionStart scan
|
|
10
10
|
* covers what loads at launch from the project root and its parents; everything
|
|
11
11
|
* else arrives here, including the user-global `~/.claude` memory and rules that
|
|
12
12
|
* load into every session on the machine — a second root that would otherwise
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* reload re-reads it, say so where the user and the model both see it, and arm
|
|
21
21
|
* the PreToolUse gate when the strip did not happen.
|
|
22
22
|
*/
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
23
24
|
import {
|
|
24
25
|
emitHookResponse,
|
|
25
26
|
HookEvent,
|
|
@@ -54,6 +55,18 @@ const { scanText, cleanFile } =
|
|
|
54
55
|
|
|
55
56
|
const HOOK_NAME = "scan-loaded-instructions";
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Read an instruction file for scanning, the same way the SessionStart scan
|
|
60
|
+
* reads its targets. Cleaning is where the symlink and UTF-8 guards live
|
|
61
|
+
* (`cleanFile` opens `O_NOFOLLOW`), so a path this read resolves but that one
|
|
62
|
+
* refuses becomes a reported finding the operator sees, never a silent rewrite.
|
|
63
|
+
* @param {string} filePath
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function readInstructions(filePath) {
|
|
67
|
+
return readFileSync(filePath, "utf-8");
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
/**
|
|
58
71
|
* The stderr line both posture arms share: what broke, and what it cost.
|
|
59
72
|
* @param {{ message: string }} ctx
|
|
@@ -89,32 +102,23 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
89
102
|
});
|
|
90
103
|
|
|
91
104
|
/**
|
|
92
|
-
* The payload fields this hook reads, validated. A payload
|
|
93
|
-
* harness-contract drift, not a clean file: reporting "no findings" for
|
|
94
|
-
* never
|
|
95
|
-
* the declared fault posture instead.
|
|
105
|
+
* The payload fields this hook reads, validated. A payload with no `file_path`
|
|
106
|
+
* is harness-contract drift, not a clean file: reporting "no findings" for a
|
|
107
|
+
* file we never identified is the one answer that must never be reachable, so
|
|
108
|
+
* this throws into the declared fault posture instead.
|
|
96
109
|
* @param {unknown} payload
|
|
97
|
-
* @returns {{ filePath: string,
|
|
110
|
+
* @returns {{ filePath: string, loadReason: string }}
|
|
98
111
|
*/
|
|
99
112
|
export function readLoadedFile(payload) {
|
|
100
|
-
const {
|
|
101
|
-
|
|
102
|
-
file_content: content,
|
|
103
|
-
load_reason: loadReason,
|
|
104
|
-
} = /** @type {Record<string, unknown>} */ (payload ?? {});
|
|
113
|
+
const { file_path: filePath, load_reason: loadReason } =
|
|
114
|
+
/** @type {Record<string, unknown>} */ (payload ?? {});
|
|
105
115
|
if (typeof filePath !== "string" || filePath === "")
|
|
106
116
|
throw new Error(
|
|
107
117
|
"InstructionsLoaded payload carries no file_path; cannot scan or report " +
|
|
108
118
|
"the instruction file that was loaded",
|
|
109
119
|
);
|
|
110
|
-
if (typeof content !== "string")
|
|
111
|
-
throw new Error(
|
|
112
|
-
`InstructionsLoaded payload for ${JSON.stringify(filePath)} carries no ` +
|
|
113
|
-
"file_content; the loaded bytes are not available to scan",
|
|
114
|
-
);
|
|
115
120
|
return {
|
|
116
121
|
filePath,
|
|
117
|
-
content,
|
|
118
122
|
// Metadata for the trace channel only, so an unknown/absent reason is a
|
|
119
123
|
// label, never a reason to skip the scan.
|
|
120
124
|
loadReason: typeof loadReason === "string" ? loadReason : "unknown",
|
|
@@ -123,23 +127,28 @@ export function readLoadedFile(payload) {
|
|
|
123
127
|
|
|
124
128
|
/**
|
|
125
129
|
* Scan one loaded instruction file. Returns the report and what to do with it:
|
|
126
|
-
* `cleaned` says the payload is gone from disk, `
|
|
127
|
-
*
|
|
130
|
+
* `cleaned` says the payload is gone from disk, and `reason` says why it is not
|
|
131
|
+
* when it is not — which is what routes the file to the PreToolUse gate.
|
|
128
132
|
*
|
|
129
|
-
* The
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
133
|
+
* The event names the file but carries none of its bytes, so the scan reads the
|
|
134
|
+
* path. That is also what keeps scan and clean coherent: `cleanFile` rewrites
|
|
135
|
+
* what is on disk, and this scan is what decides whether it should.
|
|
136
|
+
*
|
|
137
|
+
* A read that fails is never an empty findings list — an instruction file
|
|
138
|
+
* already in context that could not be scanned is exactly what this hook's fault
|
|
139
|
+
* posture exists to announce, so the error propagates to it.
|
|
140
|
+
* @param {string} filePath
|
|
141
|
+
* @param {{ projectDir?: string, clean?: typeof cleanFile,
|
|
142
|
+
* read?: (path: string) => string }} [opts] injectable for tests; the
|
|
143
|
+
* defaults read the real file and clean through the SSOT's guarded rewrite
|
|
135
144
|
* @returns {{ report: string, cleaned: boolean, reason: string | null } | null}
|
|
136
145
|
* null when the file is clean
|
|
137
146
|
*/
|
|
138
147
|
export function scanLoadedFile(
|
|
139
|
-
|
|
140
|
-
{ projectDir = PROJECT_DIR, clean = cleanFile } = {},
|
|
148
|
+
filePath,
|
|
149
|
+
{ projectDir = PROJECT_DIR, clean = cleanFile, read = readInstructions } = {},
|
|
141
150
|
) {
|
|
142
|
-
const findings = scanText(
|
|
151
|
+
const findings = scanText(read(filePath));
|
|
143
152
|
if (findings.length === 0) return null;
|
|
144
153
|
const report = formatReport([{ file: filePath, findings }]);
|
|
145
154
|
if (!isInsideDir(projectDir, filePath))
|
|
@@ -158,15 +167,12 @@ export function scanLoadedFile(
|
|
|
158
167
|
cleaned: false,
|
|
159
168
|
reason: "the file changed between the load and the clean",
|
|
160
169
|
};
|
|
161
|
-
/* c8 ignore start -- only fires on a file cleanFile refuses (symlink,
|
|
162
|
-
non-UTF-8, concurrent write) or cannot rewrite */
|
|
163
170
|
} catch (err) {
|
|
164
171
|
// A TypeError is an unbound lazy import — a bug in THIS hook — and must not
|
|
165
172
|
// be laundered into "this file resisted cleaning".
|
|
166
173
|
if (err instanceof TypeError) throw err;
|
|
167
174
|
return { report, cleaned: false, reason: safeErrMessage(err) };
|
|
168
175
|
}
|
|
169
|
-
/* c8 ignore stop */
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
/**
|
|
@@ -215,7 +221,7 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
215
221
|
// before the payload's OWN fields are validated for the same reason.
|
|
216
222
|
recordInstructionsLoaded(payload?.session_id);
|
|
217
223
|
const loaded = readLoadedFile(payload);
|
|
218
|
-
const result = scanLoadedFile(loaded);
|
|
224
|
+
const result = scanLoadedFile(loaded.filePath);
|
|
219
225
|
if (result === null) {
|
|
220
226
|
emitTrace(TraceEvent.SCAN_LOADED_INSTRUCTIONS_RAN, {
|
|
221
227
|
outcome: "clean",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.37.1",
|
|
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": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
|
|
2
|
+
"_comment": "SSOT for the payload-capable invisible code points, generated by scripts/gen-invisible-charset.mjs. `extra_codepoints` are the non-Cf extras (variation selectors, blank-rendering fillers, zero-width combining marks) from src/invisible.mjs (VS + BLANK_NON_CF). `cf_codepoints` is the general-category Cf set PINNED from Node's Unicode data at generation time (see `unicode_version`) — NOT resolved live per consumer, because Node and CPython ship different Unicode versions and a live-Cf split let a code point in the version delta escape one layer. The deletion set is the UNION of the two lists. `control_introducers` is the raw ANSI control-introducer set (ESC + the C1 block) from src/ansi.mjs, which Layer 1 sweeps and the Python textstrip port must sweep identically. `escape_sequence_pattern` is the escape GRAMMAR from that same module, as a regex source valid in JS and in Python `re` with no flags: src/ansi.mjs's scanner is the authoritative implementation, and a stdlib-only consumer compiles this rather than hand-writing a second spelling of it. Consumers in other languages read this file instead of forking the lists — a fork is a silent security regression.",
|
|
3
3
|
"unicode_version": "17.0",
|
|
4
4
|
"extra_codepoints": [
|
|
5
5
|
847,
|
|
@@ -477,5 +477,6 @@
|
|
|
477
477
|
157,
|
|
478
478
|
158,
|
|
479
479
|
159
|
|
480
|
-
]
|
|
480
|
+
],
|
|
481
|
+
"escape_sequence_pattern": "(?:(?:\\u001b[\\u005d\\u0050\\u0058\\u005e\\u005f]|[\\u0090\\u0098\\u009d\\u009e\\u009f])[^\\u0007\\u000a\\u000d\\u0018\\u001a\\u001b\\u0090\\u0098\\u009c\\u009d\\u009e\\u009f]*(?:[\\u0007\\u0018\\u001a\\u009c]|\\u001b\\u005c|(?=[\\u000a\\u000d\\u001b\\u0090\\u0098\\u009d\\u009e\\u009f])|(?![\\s\\S]))|[\\u001b\\u009b][\\[()#;?]*(?![\\[()#;?])[0-9;:]*[A-PR-TZcf-nqrty~])"
|
|
481
482
|
}
|
package/src/ansi.mjs
CHANGED
|
@@ -43,9 +43,25 @@ export const CONTROL_INTRODUCER_CODEPOINTS = Object.freeze([
|
|
|
43
43
|
// grep-based drift check as well. Derived from the code-point list above so the
|
|
44
44
|
// regex and the exported data cannot disagree; `\uXXXX` escapes keep every raw
|
|
45
45
|
// control byte out of the source (no `no-control-regex` disable needed).
|
|
46
|
-
export const CONTROL_INTRODUCER_SOURCE =
|
|
47
|
-
|
|
48
|
-
)
|
|
46
|
+
export const CONTROL_INTRODUCER_SOURCE = charClass(
|
|
47
|
+
CONTROL_INTRODUCER_CODEPOINTS,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** A code point as a `\uXXXX` escape — the one spelling of a control byte that
|
|
51
|
+
* both this module's regexes and the generated Python pattern use, so no raw
|
|
52
|
+
* control byte ever lands in either source.
|
|
53
|
+
* @param {number} cp
|
|
54
|
+
* @returns {string} */
|
|
55
|
+
function unicodeEscape(cp) {
|
|
56
|
+
return `\\u${cp.toString(16).padStart(4, "0")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A character class matching exactly the given code points.
|
|
60
|
+
* @param {readonly number[]} cps
|
|
61
|
+
* @returns {string} */
|
|
62
|
+
function charClass(cps) {
|
|
63
|
+
return `[${cps.map(unicodeEscape).join("")}]`;
|
|
64
|
+
}
|
|
49
65
|
|
|
50
66
|
// SGR (Select Graphic Rendition): colors, bold, reset. The grammar is closed:
|
|
51
67
|
// params are [0-9;:]* and the final byte is `m`, so a match can only restyle
|
|
@@ -73,10 +89,15 @@ const SGR_ANCHORED_RE = new RegExp(`^${SGR_SOURCE}$`);
|
|
|
73
89
|
// Private parameter-prefix and intermediate bytes that may follow an
|
|
74
90
|
// introducer before the parameters (`ESC[?25h`, `ESC(B`, `ESC#8`). Also covers
|
|
75
91
|
// the 7-bit `ESC [` CSI introducer's bracket itself.
|
|
76
|
-
|
|
92
|
+
// The `[` is escaped though neither engine requires it: Python's `re` warns
|
|
93
|
+
// `FutureWarning: Possible nested set` on a bare one, and this class ships as
|
|
94
|
+
// the generated pattern a Python consumer compiles.
|
|
95
|
+
const CSI_INTRO_CLASS = "[\\[()#;?]";
|
|
96
|
+
const CSI_INTRO_RE = new RegExp(CSI_INTRO_CLASS);
|
|
77
97
|
|
|
78
98
|
// ECMA-48 parameter bytes.
|
|
79
|
-
const
|
|
99
|
+
const CSI_PARAM_CLASS = "[0-9;:]";
|
|
100
|
+
const CSI_PARAM_RE = new RegExp(CSI_PARAM_CLASS);
|
|
80
101
|
|
|
81
102
|
// ECMA-48 final bytes, minus the ones a terminal never accepts here. Digits are
|
|
82
103
|
// PARAMETER bytes and can never terminate a sequence — an unterminated `ESC[`
|
|
@@ -86,15 +107,26 @@ const CSI_PARAM_RE = /[0-9;:]/;
|
|
|
86
107
|
// PARAMETER-prefix bytes per ECMA-48 § 5.4, not finals — including them let a
|
|
87
108
|
// private-marker sequence terminate one byte too early. `~` (0x7E) IS a real
|
|
88
109
|
// final byte (vt220 function keys, `ESC[3~` for Delete) and is kept.
|
|
89
|
-
const
|
|
110
|
+
const CSI_FINAL_CLASS = "[A-PR-TZcf-nqrty~]";
|
|
111
|
+
const CSI_FINAL_RE = new RegExp(CSI_FINAL_CLASS);
|
|
90
112
|
|
|
91
113
|
const ESC = 0x1b;
|
|
92
114
|
const CSI_C1 = 0x9b;
|
|
93
115
|
const ST_C1 = 0x9c;
|
|
94
116
|
const BEL = 0x07;
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
117
|
+
// THE ABORT SET — the four controls that end a control string short of its
|
|
118
|
+
// terminator, and the whole of it. Every other C0 control and DEL is
|
|
119
|
+
// deliberately consumed as body, because that is what a terminal does with
|
|
120
|
+
// them: DEC's parser (vt100.net/emu/dec_ansi_parser) IGNORES C0 other than
|
|
121
|
+
// CAN/SUB/ESC in `osc_string` and `sos_pm_apc_string` and `put`s them in
|
|
122
|
+
// `dcs_passthrough`, so aborting on `VT`/`FF`/`NUL`/`DEL` would end the token
|
|
123
|
+
// early and splice the rest of a payload the terminal swallows back into the
|
|
124
|
+
// model's view — the under-strip this layer exists to close.
|
|
125
|
+
// CAN/SUB — ECMA-48 and that same parser cancel the string here.
|
|
126
|
+
// LF/CR — NOT terminal behavior, a fail-closed blast-radius limit: they
|
|
127
|
+
// are the only two controls that cross a line, and a body running
|
|
128
|
+
// past one blinds a reader who consumes the strip as a RECORD
|
|
129
|
+
// rather than rendering it (see scanControlString).
|
|
98
130
|
const CAN = 0x18;
|
|
99
131
|
const SUB = 0x1a;
|
|
100
132
|
const LF = 0x0a;
|
|
@@ -120,6 +152,59 @@ const OSC_C1 = 0x9d;
|
|
|
120
152
|
// set: opening a string is exactly what ends the one already open.
|
|
121
153
|
const STRING_INTRO_C1 = new Set([0x90, 0x98, OSC_C1, 0x9e, 0x9f]);
|
|
122
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
|
|
157
|
+
* artifact for a consumer that cannot run this module.
|
|
158
|
+
*
|
|
159
|
+
* The scanner below is AUTHORITATIVE and this is derived from its own constants,
|
|
160
|
+
* never the other way round: the scanner emits token KINDS a regex cannot, and
|
|
161
|
+
* it is linear by construction where the regex form has to carry an explicit
|
|
162
|
+
* guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
|
|
163
|
+
* a stdlib-only Python filter on an uncontrolled host, with no install path for
|
|
164
|
+
* this package, can read a pattern string but cannot import a tokenizer. So the
|
|
165
|
+
* generator pins this into `data/invisible-charset.json` beside the introducer
|
|
166
|
+
* set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
|
|
167
|
+
* hand-written spellings of one grammar.
|
|
168
|
+
*
|
|
169
|
+
* Every construct here is common to JS and Python `re` with NO flags —
|
|
170
|
+
* `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
|
|
171
|
+
* `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
|
|
172
|
+
* — so ONE pattern string is what both engines read.
|
|
173
|
+
* `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
|
|
174
|
+
* corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
|
|
175
|
+
*/
|
|
176
|
+
export const ESCAPE_SEQUENCE_SOURCE = (() => {
|
|
177
|
+
const introducer7Bit = `${unicodeEscape(ESC)}${charClass(
|
|
178
|
+
[...STRING_INTRO_7BIT].map((ch) => ch.charCodeAt(0)),
|
|
179
|
+
)}`;
|
|
180
|
+
const c1Introducers = [...STRING_INTRO_C1].sort((a, b) => a - b);
|
|
181
|
+
// Consumed with the body, vs the bytes the token ends BEFORE (zero-width) so
|
|
182
|
+
// the scan re-reads them — the split scanControlString makes byte for byte.
|
|
183
|
+
const consumed = [BEL, CAN, SUB, ST_C1].sort((a, b) => a - b);
|
|
184
|
+
const abortBefore = [ESC, ...c1Introducers, LF, CR].sort((a, b) => a - b);
|
|
185
|
+
const body = `[^${[...new Set([...consumed, ...abortBefore])]
|
|
186
|
+
.sort((a, b) => a - b)
|
|
187
|
+
.map(unicodeEscape)
|
|
188
|
+
.join("")}]*`;
|
|
189
|
+
// `ESC \` is the 7-bit ST, the one two-byte terminator.
|
|
190
|
+
const escapeSt = `${unicodeEscape(ESC)}${unicodeEscape(0x5c)}`;
|
|
191
|
+
const terminator =
|
|
192
|
+
`(?:${charClass(consumed)}|${escapeSt}` +
|
|
193
|
+
`|(?=${charClass(abortBefore)})|(?![\\s\\S]))`;
|
|
194
|
+
const stringArm = `(?:${introducer7Bit}|${charClass(c1Introducers)})${body}${terminator}`;
|
|
195
|
+
// The negative lookahead pins the intro run MAXIMAL — which is what the
|
|
196
|
+
// scanner's `while` loop does — and in doing so removes the only place the
|
|
197
|
+
// two quantifiers could repartition (`;` is in both classes), so this cannot
|
|
198
|
+
// backtrack super-linearly the way an unbounded `[…;…]*[…;…]*` would.
|
|
199
|
+
const csiArm =
|
|
200
|
+
`${charClass([ESC, CSI_C1])}${CSI_INTRO_CLASS}*(?!${CSI_INTRO_CLASS})` +
|
|
201
|
+
`${CSI_PARAM_CLASS}*${CSI_FINAL_CLASS}`;
|
|
202
|
+
// The string arm runs FIRST for the same reason it does in scanAnsi: `P` is
|
|
203
|
+
// also a CSI final byte, so a CSI-first alternation takes `ESC P` alone and
|
|
204
|
+
// leaves the DCS body as visible text.
|
|
205
|
+
return `(?:${stringArm}|${csiArm})`;
|
|
206
|
+
})();
|
|
207
|
+
|
|
123
208
|
/** The seven things an introducer can turn out to be. */
|
|
124
209
|
export const TOKEN_KIND = Object.freeze({
|
|
125
210
|
/** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
|
|
@@ -230,7 +315,9 @@ export function orphanKindFor(ch, next) {
|
|
|
230
315
|
* bound one stray `ESC ]` deleted every later line to end of input, so on a
|
|
231
316
|
* consumer that reads the strip as a RECORD (a model, not a display) one
|
|
232
317
|
* introducer blinded the whole tail behind a clean-looking prefix. The
|
|
233
|
-
* break survives; the payload after it on the same line is dropped.
|
|
318
|
+
* break survives; the payload after it on the same line is dropped. This is
|
|
319
|
+
* what makes the layer-wide invariant hold — no token of any kind spans a
|
|
320
|
+
* line break, so a strip NEVER removes a newline (test/layer1-ansi).
|
|
234
321
|
* 4. end of input, for a genuinely unterminated string with no line break:
|
|
235
322
|
* fail closed and drop everything from the introducer on, so no body
|
|
236
323
|
* survives.
|
package/types/ansi.d.mts
CHANGED
|
@@ -65,6 +65,28 @@ export const CONTROL_INTRODUCER_SOURCE: string;
|
|
|
65
65
|
* and the regex can no longer describe different languages.
|
|
66
66
|
*/
|
|
67
67
|
export const SGR_RE: RegExp;
|
|
68
|
+
/**
|
|
69
|
+
* The same grammar {@link scanAnsi} implements, as a REGEX SOURCE — the shipped
|
|
70
|
+
* artifact for a consumer that cannot run this module.
|
|
71
|
+
*
|
|
72
|
+
* The scanner below is AUTHORITATIVE and this is derived from its own constants,
|
|
73
|
+
* never the other way round: the scanner emits token KINDS a regex cannot, and
|
|
74
|
+
* it is linear by construction where the regex form has to carry an explicit
|
|
75
|
+
* guard to stay linear (see the CSI arm's lookahead). What a regex CAN be is data —
|
|
76
|
+
* a stdlib-only Python filter on an uncontrolled host, with no install path for
|
|
77
|
+
* this package, can read a pattern string but cannot import a tokenizer. So the
|
|
78
|
+
* generator pins this into `data/invisible-charset.json` beside the introducer
|
|
79
|
+
* set, `agent_sanitizer.textstrip` compiles it, and the two ports stop being two
|
|
80
|
+
* hand-written spellings of one grammar.
|
|
81
|
+
*
|
|
82
|
+
* Every construct here is common to JS and Python `re` with NO flags —
|
|
83
|
+
* `\uXXXX`, `(?:)`, `(?=)`, `(?!)`, and `(?![\s\S])` for end-of-input (Python's
|
|
84
|
+
* `$` also matches before a trailing newline, JS's does not; `\Z` is Python-only)
|
|
85
|
+
* — so ONE pattern string is what both engines read.
|
|
86
|
+
* `test/ansi-pattern-parity.test.mjs` runs it against the scanner over a fuzz
|
|
87
|
+
* corpus; `tests/test_textstrip.py` asserts it compiles under plain `re`.
|
|
88
|
+
*/
|
|
89
|
+
export const ESCAPE_SEQUENCE_SOURCE: string;
|
|
68
90
|
/** The seven things an introducer can turn out to be. */
|
|
69
91
|
export const TOKEN_KIND: Readonly<{
|
|
70
92
|
/** A display-only `ESC[…m` / `U+009B…m` colour sequence. */
|
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The payload fields this hook reads, validated. A payload
|
|
3
|
-
* harness-contract drift, not a clean file: reporting "no findings" for
|
|
4
|
-
* never
|
|
5
|
-
* the declared fault posture instead.
|
|
2
|
+
* The payload fields this hook reads, validated. A payload with no `file_path`
|
|
3
|
+
* is harness-contract drift, not a clean file: reporting "no findings" for a
|
|
4
|
+
* file we never identified is the one answer that must never be reachable, so
|
|
5
|
+
* this throws into the declared fault posture instead.
|
|
6
6
|
* @param {unknown} payload
|
|
7
|
-
* @returns {{ filePath: string,
|
|
7
|
+
* @returns {{ filePath: string, loadReason: string }}
|
|
8
8
|
*/
|
|
9
9
|
export function readLoadedFile(payload: unknown): {
|
|
10
10
|
filePath: string;
|
|
11
|
-
content: string;
|
|
12
11
|
loadReason: string;
|
|
13
12
|
};
|
|
14
13
|
/**
|
|
15
14
|
* Scan one loaded instruction file. Returns the report and what to do with it:
|
|
16
|
-
* `cleaned` says the payload is gone from disk, `
|
|
17
|
-
*
|
|
15
|
+
* `cleaned` says the payload is gone from disk, and `reason` says why it is not
|
|
16
|
+
* when it is not — which is what routes the file to the PreToolUse gate.
|
|
18
17
|
*
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
* The event names the file but carries none of its bytes, so the scan reads the
|
|
19
|
+
* path. That is also what keeps scan and clean coherent: `cleanFile` rewrites
|
|
20
|
+
* what is on disk, and this scan is what decides whether it should.
|
|
21
|
+
*
|
|
22
|
+
* A read that fails is never an empty findings list — an instruction file
|
|
23
|
+
* already in context that could not be scanned is exactly what this hook's fault
|
|
24
|
+
* posture exists to announce, so the error propagates to it.
|
|
25
|
+
* @param {string} filePath
|
|
26
|
+
* @param {{ projectDir?: string, clean?: typeof cleanFile,
|
|
27
|
+
* read?: (path: string) => string }} [opts] injectable for tests; the
|
|
28
|
+
* defaults read the real file and clean through the SSOT's guarded rewrite
|
|
25
29
|
* @returns {{ report: string, cleaned: boolean, reason: string | null } | null}
|
|
26
30
|
* null when the file is clean
|
|
27
31
|
*/
|
|
28
|
-
export function scanLoadedFile({
|
|
29
|
-
filePath: string;
|
|
30
|
-
content: string;
|
|
31
|
-
}, { projectDir, clean }?: {
|
|
32
|
+
export function scanLoadedFile(filePath: string, { projectDir, clean, read }?: {
|
|
32
33
|
projectDir?: string;
|
|
33
34
|
clean?: typeof cleanFile;
|
|
35
|
+
read?: (path: string) => string;
|
|
34
36
|
}): {
|
|
35
37
|
report: string;
|
|
36
38
|
cleaned: boolean;
|