agent-sanitizer 2.0.0

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.
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Single-source-of-truth CLI over the sanitizer's data-in/data-out entry points,
4
+ * for non-JS pipelines.
5
+ *
6
+ * The logic lives once, in `src/`. This CLI is the supported escape hatch for
7
+ * callers that can't import the JavaScript directly (a Python pipeline, say): it
8
+ * speaks JSON over stdin/stdout so any language drives the exact same verdicts
9
+ * without a second implementation to keep in sync. Only the entry points with no
10
+ * injected callback are exposed — the agent-pipeline seams that take a homoglyph
11
+ * scanner, redactor, or file-access object have no language-agnostic wire form
12
+ * and stay JS-only.
13
+ *
14
+ * Protocol — a request is a JSON object with an `op` (default `"sanitize"` so a
15
+ * bare `{ text, html }` keeps working). Per op:
16
+ *
17
+ * sanitize { text, html? } -> { cleaned, found, warnings }
18
+ * sanitizeText { text, html?, exfilScan? } -> { cleaned, warnings, modified, sgrNote }
19
+ * classifyPrompt { text } -> { action, reason? }
20
+ * scanInstructionFiles { globs, cwd? } -> { findings: [{ file, findings }] }
21
+ * cleanFile { path } -> { changed }
22
+ *
23
+ * A failure response is `{ "error": string }`. Two modes, same binary:
24
+ *
25
+ * one-shot (default): read ONE JSON object from stdin (may span lines), write
26
+ * ONE response line. A malformed request propagates — non-zero exit, a
27
+ * one-line reason on stderr (the message, deliberately not a raw Node stack)
28
+ * — so a scripted caller fails loudly.
29
+ *
30
+ * worker (`--worker`): read newline-delimited JSON requests until EOF, write
31
+ * EXACTLY one response line per input line, in order — including for a blank
32
+ * line (a framing slip on the caller's side), which gets an `{ "error" }`
33
+ * line rather than being silently skipped. Skipping a line desyncs every
34
+ * later response against a client that reads one response per request. A
35
+ * malformed request likewise yields an `{ "error" }` line and the worker
36
+ * keeps serving — the specific, necessary recovery that makes a long-lived
37
+ * process usable across independent requests (one bad line must not drop the
38
+ * whole pipe). JSON string-encodes every newline, so one request and one
39
+ * response always occupy one line each.
40
+ *
41
+ * Input-size cap: both modes reject a single request larger than
42
+ * `AGENT_SANITIZER_MAX_INPUT_BYTES` (UTF-8 bytes, default 10 MiB) with a
43
+ * structured error rather than buffering an unbounded payload into memory —
44
+ * one-shot exits non-zero, the worker emits an `{ "error" }` line and keeps
45
+ * serving. Raise or lower the limit via that environment variable.
46
+ */
47
+ import { Buffer } from "node:buffer";
48
+ import { realpathSync } from "node:fs";
49
+ import process from "node:process";
50
+ import { fileURLToPath } from "node:url";
51
+
52
+ import { sanitize } from "../src/index.mjs";
53
+
54
+ /** Largest single request accepted, in UTF-8 bytes. Caps memory per request so
55
+ * a hostile or runaway caller can't OOM the process; override via the env var. */
56
+ const DEFAULT_MAX_INPUT_BYTES = 10 * 1024 * 1024;
57
+
58
+ /** Resolve the configured input cap, falling back to the default for an unset,
59
+ * empty, non-numeric, or non-positive value. */
60
+ function maxInputBytes() {
61
+ const raw = process.env.AGENT_SANITIZER_MAX_INPUT_BYTES;
62
+ const parsed = Number(raw);
63
+ if (
64
+ raw === undefined ||
65
+ raw === "" ||
66
+ !Number.isFinite(parsed) ||
67
+ parsed <= 0
68
+ )
69
+ return DEFAULT_MAX_INPUT_BYTES;
70
+ return Math.floor(parsed);
71
+ }
72
+
73
+ /** Throw if `text` exceeds the configured byte cap. The message names the limit
74
+ * and the env var so a caller can act on it. */
75
+ function enforceSizeLimit(text) {
76
+ const limit = maxInputBytes();
77
+ const size = Buffer.byteLength(text, "utf8");
78
+ if (size > limit)
79
+ throw new Error(
80
+ `request too large: ${size} bytes exceeds the ${limit}-byte limit ` +
81
+ "(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
82
+ );
83
+ }
84
+
85
+ /** @param {Record<string, unknown>} req @param {string} key */
86
+ function requireString(req, key) {
87
+ if (typeof req[key] !== "string")
88
+ throw new Error(`request.${key} must be a string`);
89
+ }
90
+
91
+ /** Operations the CLI exposes. Each takes the parsed request, returns the JSON
92
+ * payload object. Non-`sanitize` modules are imported lazily so a caller that
93
+ * only ever sanitizes never loads prompt/output/instructions code. */
94
+ const OPS = {
95
+ async sanitize(req) {
96
+ requireString(req, "text");
97
+ const { cleaned, found, warnings } = await sanitize(req.text, {
98
+ html: Boolean(req.html),
99
+ });
100
+ return { cleaned, found, warnings };
101
+ },
102
+
103
+ async sanitizeText(req) {
104
+ requireString(req, "text");
105
+ // Layers 1–3 only: redact (Layer 4) and filterInjection (Layer 5) are
106
+ // injected JS callbacks with no wire form, so they're never set here.
107
+ const { sanitizeText } = await import("../src/output.mjs");
108
+ const { cleaned, warnings, modified, sgrNote } = await sanitizeText(
109
+ req.text,
110
+ {
111
+ html: Boolean(req.html),
112
+ exfilScan: Boolean(req.exfilScan),
113
+ },
114
+ );
115
+ return { cleaned, warnings, modified, sgrNote };
116
+ },
117
+
118
+ async classifyPrompt(req) {
119
+ requireString(req, "text");
120
+ const { classifyPrompt } = await import("../src/prompt.mjs");
121
+ return classifyPrompt(req.text);
122
+ },
123
+
124
+ // SECURITY (R8): `scanInstructionFiles` and `cleanFile` take filesystem
125
+ // paths/globs straight from the request and read (and, for cleanFile, WRITE)
126
+ // whatever they resolve to, with NO root confinement — `cwd`/`path`/`globs`
127
+ // may escape any directory (absolute paths, `..`, symlinks). Unlike the
128
+ // text-in/text-out ops, the request here names files on the host. The stdin
129
+ // peer that sends these requests must therefore be FULLY TRUSTED: never wire
130
+ // this CLI's stdin to untrusted/model-controlled input for these ops. If you
131
+ // must accept untrusted callers, add opt-in root confinement (reject paths
132
+ // that resolve outside an allow-listed root) before exposing them.
133
+ async scanInstructionFiles(req) {
134
+ if (
135
+ !Array.isArray(req.globs) ||
136
+ req.globs.some((g) => typeof g !== "string")
137
+ )
138
+ throw new Error("request.globs must be an array of strings");
139
+ // Fail loud on a present-but-non-string cwd rather than silently dropping it
140
+ // and scanning process.cwd() — a wrong-scope scan is worse than a clear error
141
+ // (matches the fail-loud contract every other typed field here follows).
142
+ if ("cwd" in req && typeof req.cwd !== "string")
143
+ throw new Error("request.cwd must be a string");
144
+ const { scanInstructionFiles } = await import("../src/instructions.mjs");
145
+ const opts = typeof req.cwd === "string" ? { cwd: req.cwd } : {};
146
+ return { findings: scanInstructionFiles(req.globs, opts) };
147
+ },
148
+
149
+ async cleanFile(req) {
150
+ requireString(req, "path");
151
+ const { cleanFile } = await import("../src/instructions.mjs");
152
+ return { changed: cleanFile(req.path) };
153
+ },
154
+ };
155
+
156
+ /**
157
+ * Run one request through the named `op` and serialize the response.
158
+ * Throws on a malformed request or unknown op; the caller decides whether that
159
+ * propagates (one-shot) or becomes an `{ error }` line (worker).
160
+ * @param {string} payload a single JSON request object
161
+ * @returns {Promise<string>} a single-line JSON response
162
+ */
163
+ async function handle(payload) {
164
+ enforceSizeLimit(payload);
165
+ const request = JSON.parse(payload);
166
+ const op = request.op ?? "sanitize";
167
+ const run = Object.prototype.hasOwnProperty.call(OPS, op) ? OPS[op] : null;
168
+ if (!run) throw new Error(`unknown op: ${op}`);
169
+ return JSON.stringify(await run(request));
170
+ }
171
+
172
+ /** Read the whole stream as UTF-8, aborting as soon as the accumulated bytes
173
+ * exceed the cap so a hostile one-shot caller can't force unbounded buffering.
174
+ * @param {NodeJS.ReadableStream} stream */
175
+ async function readAll(stream) {
176
+ stream.setEncoding("utf8");
177
+ const limit = maxInputBytes();
178
+ let text = "";
179
+ let bytes = 0;
180
+ for await (const chunk of stream) {
181
+ bytes += Buffer.byteLength(chunk, "utf8");
182
+ if (bytes > limit)
183
+ throw new Error(
184
+ `request too large: input exceeds the ${limit}-byte limit ` +
185
+ "(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
186
+ );
187
+ text += chunk;
188
+ }
189
+ return text;
190
+ }
191
+
192
+ /**
193
+ * Streaming newline-splitter that never buffers a line past the byte `limit`.
194
+ *
195
+ * Fed raw stdin chunks one at a time, it yields a sequence of events the worker
196
+ * turns into exactly one response line each. The reason this exists instead of
197
+ * `readline`: `readline` buffers an entire newline-less line into memory before
198
+ * handing it over, so a hostile caller streaming a multi-gigabyte line with no
199
+ * `\n` defeats the size cap (the cap only fires once the whole line is already
200
+ * resident). Here the running byte length of the CURRENT unterminated line is
201
+ * tracked as bytes arrive; the instant it exceeds `limit` the partial buffer is
202
+ * dropped and the splitter enters a "resync" state that discards every byte up
203
+ * to (and including) the next `\n`, so peak buffering for one line is bounded by
204
+ * `limit` plus one chunk — never the whole line.
205
+ *
206
+ * Events: `{ kind: "line", text }` for a complete in-cap line (trailing `\r`
207
+ * stripped, matching `readline`'s `crlfDelay: Infinity` CRLF handling), and
208
+ * `{ kind: "oversize" }` for a line whose bytes crossed `limit` (emitted once,
209
+ * at the newline that terminates the discarded line). The worker maps the
210
+ * former through `handle` and the latter to a "request too large" error line, so
211
+ * the one-response-per-input-line framing holds even for a dropped line.
212
+ *
213
+ * @param {number} limit per-line byte cap (`maxInputBytes()`)
214
+ */
215
+ function createLineSplitter(limit) {
216
+ // The bytes of the current line are held as a LIST of chunk slices plus their
217
+ // running total, joined into one Buffer only when the line completes. Pushing
218
+ // a slice is O(1), so assembling an N-byte line costs O(N) overall. The old
219
+ // single growing `Buffer.concat([buffer, segment])` per segment re-copied the
220
+ // whole accumulated prefix each time — O(N^2) for a line streamed as many
221
+ // small chunks (e.g. a multi-MiB line arriving in 1-byte reads). `discarding`
222
+ // still caps peak buffering at `limit`: once a line would breach it, the
223
+ // pending slices are dropped and we scan only for the terminating `\n`.
224
+ /** @type {Buffer[]} */
225
+ let pending = [];
226
+ let pendingLen = 0;
227
+ let discarding = false;
228
+
229
+ /** Drop any pending slices (line abandoned or fully consumed). */
230
+ const reset = () => {
231
+ pending = [];
232
+ pendingLen = 0;
233
+ };
234
+
235
+ /** Join the pending slices into one line buffer (single copy) and reset. */
236
+ const take = () => {
237
+ const buf =
238
+ pending.length === 1 ? pending[0] : Buffer.concat(pending, pendingLen);
239
+ reset();
240
+ return buf;
241
+ };
242
+
243
+ /** Strip one trailing `\r` so CRLF input frames identically to LF. */
244
+ const toLine = (buf) => {
245
+ const stripCr = buf.length > 0 && buf[buf.length - 1] === 0x0d;
246
+ return buf.toString("utf8", 0, stripCr ? buf.length - 1 : buf.length);
247
+ };
248
+
249
+ // Fold one segment (the bytes of the current line seen so far in this chunk)
250
+ // into the pending list, flipping to `discarding` if it would breach the cap.
251
+ // Applies identically to a newline-terminated segment and to the unterminated
252
+ // tail, so an oversize line is caught WITHIN a chunk, not only at its edge.
253
+ const accumulate = (segment) => {
254
+ if (discarding) return;
255
+ if (pendingLen + segment.length > limit) {
256
+ reset();
257
+ discarding = true;
258
+ return;
259
+ }
260
+ if (segment.length > 0) {
261
+ pending.push(segment);
262
+ pendingLen += segment.length;
263
+ }
264
+ };
265
+
266
+ /** Feed one chunk, returning the events it completes (newline-terminated). */
267
+ const push = (chunk) => {
268
+ const events = [];
269
+ let start = 0;
270
+ for (let i = 0; i < chunk.length; i++) {
271
+ if (chunk[i] !== 0x0a) continue;
272
+ accumulate(chunk.subarray(start, i));
273
+ if (discarding) {
274
+ events.push({ kind: "oversize" });
275
+ discarding = false;
276
+ reset();
277
+ } else {
278
+ events.push({ kind: "line", text: toLine(take()) });
279
+ }
280
+ start = i + 1;
281
+ }
282
+ accumulate(chunk.subarray(start));
283
+ return events;
284
+ };
285
+
286
+ /**
287
+ * Flush at EOF. A final line with no trailing `\n` is still a request, so it
288
+ * gets a response — matching `readline`, which emits its last line on `close`.
289
+ * An empty tail (stream ended on a `\n`, or was empty) yields nothing.
290
+ */
291
+ push.end = () => {
292
+ if (discarding) {
293
+ discarding = false;
294
+ reset();
295
+ return [{ kind: "oversize" }];
296
+ }
297
+ if (pendingLen === 0) return [];
298
+ return [{ kind: "line", text: toLine(take()) }];
299
+ };
300
+
301
+ return push;
302
+ }
303
+
304
+ const OVERSIZE_ERROR = (limit) =>
305
+ JSON.stringify({
306
+ error:
307
+ `request too large: input exceeds the ${limit}-byte limit ` +
308
+ "(raise AGENT_SANITIZER_MAX_INPUT_BYTES to accept it)",
309
+ });
310
+
311
+ async function runWorker() {
312
+ // Stream raw bytes (no encoding) so the splitter tracks UTF-8 byte length, not
313
+ // decoded characters, and a multi-gigabyte newline-less line is discarded as
314
+ // it streams rather than buffered whole the way `readline` would.
315
+ const limit = maxInputBytes();
316
+ const split = createLineSplitter(limit);
317
+
318
+ // EXACTLY one response line per input line. A complete in-cap line goes
319
+ // through `handle`; its try/catch is the worker's reason to exist — a single
320
+ // malformed request reports an error and serving continues rather than
321
+ // tearing down a pipe other requests still use. An oversize line (its bytes
322
+ // crossed the cap, so it was discarded unbuffered) gets the same structured
323
+ // "request too large" error a one-shot caller sees. `response` never holds a
324
+ // newline: `JSON.stringify` of the result or of a one-key error object is
325
+ // single-line, so the one-line-per-request framing holds.
326
+ const respond = async (event) => {
327
+ if (event.kind === "oversize") {
328
+ process.stdout.write(`${OVERSIZE_ERROR(limit)}\n`);
329
+ return;
330
+ }
331
+ let response;
332
+ try {
333
+ response = await handle(event.text);
334
+ } catch (err) {
335
+ response = JSON.stringify({ error: err?.message ?? String(err) });
336
+ }
337
+ process.stdout.write(`${response}\n`);
338
+ };
339
+
340
+ for await (const chunk of process.stdin) {
341
+ for (const event of split(Buffer.from(chunk))) await respond(event);
342
+ }
343
+ for (const event of split.end()) await respond(event);
344
+ }
345
+
346
+ async function runOneShot() {
347
+ // Fail loudly but cleanly: a bad request (or a contract-impossible `sanitize`
348
+ // throw) exits non-zero with a one-line reason on stderr — not a raw Node
349
+ // stack, which leaks internals and is noise for a scripted caller. The
350
+ // message carries the sanitizer-CLI prefix so a wrapping client can attribute
351
+ // the failure to this bridge.
352
+ let response;
353
+ try {
354
+ response = await handle(await readAll(process.stdin));
355
+ } catch (err) {
356
+ process.stderr.write(`sanitize CLI: ${err?.message ?? String(err)}\n`);
357
+ process.exitCode = 1;
358
+ return;
359
+ }
360
+ process.stdout.write(`${response}\n`);
361
+ }
362
+
363
+ // Run only when invoked as a script, not when imported (a unit test imports
364
+ // `createLineSplitter` directly and must not have the worker consume its stdin).
365
+ // Compare REAL paths: the published bin is launched through a
366
+ // `node_modules/.bin/sanitize-cli` symlink, so `process.argv[1]` is the symlink
367
+ // path while `import.meta.url` is this module's real file — a raw URL compare
368
+ // would be false and the CLI would silently produce no output. `realpathSync`
369
+ // resolves both to the same on-disk file. A non-existent `argv[1]` (e.g. an
370
+ // `--eval` entry) throws ENOENT and is treated as "not our script".
371
+ function invokedAsScript() {
372
+ if (process.argv[1] === undefined) return false;
373
+ try {
374
+ return (
375
+ realpathSync(process.argv[1]) ===
376
+ realpathSync(fileURLToPath(import.meta.url))
377
+ );
378
+ } catch {
379
+ return false;
380
+ }
381
+ }
382
+ export const USAGE = `sanitize-cli — sanitize untrusted text before an LLM sees it.
383
+
384
+ Reads JSON on stdin, writes one JSON response line on stdout.
385
+
386
+ Usage:
387
+ sanitize-cli one-shot: read ONE request object from stdin, write one response
388
+ sanitize-cli --worker worker: read newline-delimited requests until EOF, one response per line
389
+ sanitize-cli --help show this help
390
+
391
+ Request: { "op"?: string, ...fields }. Ops: sanitize (default), sanitizeText,
392
+ classifyPrompt, scanInstructionFiles, cleanFile. A failure response is { "error": string }.
393
+ `;
394
+
395
+ /**
396
+ * Resolve argv to a run mode. `-h`/`--help` prints usage; an unrecognized `-…`
397
+ * flag is a usage error rather than a silent fall-through to one-shot mode,
398
+ * which would block reading stdin from a TTY with no output. The only real flag
399
+ * is `--worker`; a bare invocation (or any non-flag arg) is one-shot.
400
+ * @param {string[]} argv process.argv (argv[0]=node, argv[1]=script)
401
+ * @returns {{ mode: "help" | "worker" | "oneshot" | "error", unknown?: string[] }}
402
+ */
403
+ export function parseArgs(argv) {
404
+ const flags = argv.slice(2).filter((arg) => arg.startsWith("-"));
405
+ if (flags.includes("-h") || flags.includes("--help")) return { mode: "help" };
406
+ const unknown = flags.filter((flag) => flag !== "--worker");
407
+ if (unknown.length > 0) return { mode: "error", unknown };
408
+ return { mode: flags.includes("--worker") ? "worker" : "oneshot" };
409
+ }
410
+
411
+ if (invokedAsScript()) {
412
+ const parsed = parseArgs(process.argv);
413
+ if (parsed.mode === "help") process.stdout.write(USAGE);
414
+ else if (parsed.mode === "error") {
415
+ process.stderr.write(
416
+ `sanitize CLI: unrecognized option(s): ${/** @type {string[]} */ (parsed.unknown).join(", ")}\n\n${USAGE}`,
417
+ );
418
+ process.exitCode = 2;
419
+ } else if (parsed.mode === "worker") await runWorker();
420
+ else await runOneShot();
421
+ }
422
+
423
+ export { createLineSplitter };
package/package.json ADDED
@@ -0,0 +1,157 @@
1
+ {
2
+ "name": "agent-sanitizer",
3
+ "version": "2.0.0",
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
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/AlexanderMattTurner/agent-sanitizer.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/AlexanderMattTurner/agent-sanitizer/issues"
12
+ },
13
+ "homepage": "https://github.com/AlexanderMattTurner/agent-sanitizer#readme",
14
+ "author": "Alexander Turner",
15
+ "packageManager": "pnpm@11.8.0",
16
+ "bin": {
17
+ "sanitize-cli": "bin/sanitize-cli.mjs"
18
+ },
19
+ "scripts": {
20
+ "prepare": "pnpm build:types && if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git config core.hooksPath .hooks; fi",
21
+ "test": "c8 node --test",
22
+ "coverage": "c8 node --test",
23
+ "check": "tsc --noEmit",
24
+ "typecheck": "tsc --noEmit",
25
+ "build:types": "tsc -p tsconfig.build.json",
26
+ "prepack": "pnpm build:types",
27
+ "gen:joining-type": "node scripts/gen-joining-type.mjs",
28
+ "lint": "eslint .",
29
+ "test:mutation": "stryker run",
30
+ "format": "prettier --write .",
31
+ "format:check": "prettier --check ."
32
+ },
33
+ "lint-staged": {
34
+ "*.{css,scss}": [
35
+ "prettier --write"
36
+ ],
37
+ "*.json": [
38
+ "prettier --write"
39
+ ],
40
+ "*.json.example": [
41
+ "prettier --write --parser json"
42
+ ],
43
+ "*.{js,jsx,mjs,cjs,ts,tsx}": [
44
+ "prettier --write"
45
+ ],
46
+ "*.{yaml,yml}": [
47
+ "prettier --write"
48
+ ],
49
+ "*.md": [
50
+ "prettier --write"
51
+ ],
52
+ ".claude/skills/*/SKILL.md": [
53
+ ".hooks/lint-skills.sh"
54
+ ],
55
+ "{*.sh,.hooks/!(*.*)}": [
56
+ "shfmt -i 2 -w"
57
+ ],
58
+ "*.py": [
59
+ "ruff check --fix",
60
+ "ruff format"
61
+ ]
62
+ },
63
+ "devDependencies": {
64
+ "@commitlint/cli": "^21.0.1",
65
+ "@commitlint/config-conventional": "^21.0.1",
66
+ "@eslint/js": "10.0.1",
67
+ "@stryker-mutator/core": "^9.6.1",
68
+ "@stryker-mutator/tap-runner": "^9.6.1",
69
+ "@types/node": "25.9.1",
70
+ "c8": "11.0.0",
71
+ "esbuild": "0.28.1",
72
+ "eslint": "10.4.0",
73
+ "fast-check": "4.8.0",
74
+ "globals": "17.6.0",
75
+ "lint-staged": "^17.0.5",
76
+ "prettier": "^3.0.0",
77
+ "typescript": "6.0.3",
78
+ "typescript-eslint": "8.61.0"
79
+ },
80
+ "license": "Apache-2.0",
81
+ "engines": {
82
+ "node": ">=22"
83
+ },
84
+ "keywords": [
85
+ "llm",
86
+ "prompt-injection",
87
+ "sanitize",
88
+ "unicode",
89
+ "invisible-characters",
90
+ "ansi",
91
+ "exfiltration",
92
+ "rag",
93
+ "agent",
94
+ "security",
95
+ "homoglyph",
96
+ "confusables",
97
+ "redaction",
98
+ "tool-output",
99
+ "edit-repair"
100
+ ],
101
+ "exports": {
102
+ ".": {
103
+ "types": "./types/index.d.mts",
104
+ "default": "./src/index.mjs"
105
+ },
106
+ "./invisible": {
107
+ "types": "./types/invisible.d.mts",
108
+ "default": "./src/invisible.mjs"
109
+ },
110
+ "./html": {
111
+ "types": "./types/html.d.mts",
112
+ "default": "./src/html.mjs"
113
+ },
114
+ "./confusables": {
115
+ "types": "./types/confusables.d.mts",
116
+ "default": "./src/confusables.mjs"
117
+ },
118
+ "./instructions": {
119
+ "types": "./types/instructions.d.mts",
120
+ "default": "./src/instructions.mjs"
121
+ },
122
+ "./prompt": {
123
+ "types": "./types/prompt.d.mts",
124
+ "default": "./src/prompt.mjs"
125
+ },
126
+ "./output": {
127
+ "types": "./types/output.d.mts",
128
+ "default": "./src/output.mjs"
129
+ },
130
+ "./view-map": {
131
+ "types": "./types/view-map.d.mts",
132
+ "default": "./src/view-map.mjs"
133
+ },
134
+ "./rehydrate": {
135
+ "types": "./types/rehydrate.d.mts",
136
+ "default": "./src/rehydrate.mjs"
137
+ }
138
+ },
139
+ "files": [
140
+ "src/*.mjs",
141
+ "bin/sanitize-cli.mjs",
142
+ "types",
143
+ "LICENSE",
144
+ "README.md",
145
+ "THREAT-MODEL.md",
146
+ "SECURITY.md"
147
+ ],
148
+ "dependencies": {
149
+ "css-tree": "^3.2.1",
150
+ "rehype-parse": "9.0.1",
151
+ "remark-gfm": "4.0.1",
152
+ "remark-parse": "11.0.0",
153
+ "style-to-object": "1.0.14",
154
+ "unified": "11.0.5",
155
+ "unist-util-visit": "5.1.0"
156
+ }
157
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * GENERATED by scripts/gen-invisible-charset.mjs from Node's Unicode data
3
+ * (\p{Cf}, Unicode 17.0) — DO NOT EDIT.
4
+ *
5
+ * The general-category Cf code points, PINNED at generation time. invisible.mjs
6
+ * strips exactly this set instead of testing \p{Cf} live, and the Python port
7
+ * reads the SAME set from data/invisible-charset.json's `cf_codepoints`, so both
8
+ * layers strip an identical Cf set regardless of each runtime's own Unicode
9
+ * version. Regenerate with `node scripts/gen-invisible-charset.mjs`;
10
+ * test/invisible-charset.test.mjs fails if this drifts from Node's \p{Cf}.
11
+ */
12
+
13
+ export const UNICODE_VERSION = "17.0";
14
+
15
+ // GENERATED Unicode data, not hand-written logic. Its correctness is pinned by
16
+ // the SSOT round-trip in test/invisible-charset.test.mjs (re-enumerate \p{Cf},
17
+ // then assert every code point matches) — Stryker skips the numeric literals.
18
+ // Stryker disable all
19
+ /** @type {readonly number[]} Sorted ascending. */
20
+ export const CF_CODEPOINTS = Object.freeze([
21
+ 0xad, 0x600, 0x601, 0x602, 0x603, 0x604, 0x605, 0x61c, 0x6dd, 0x70f, 0x890,
22
+ 0x891, 0x8e2, 0x180e, 0x200b, 0x200c, 0x200d, 0x200e, 0x200f, 0x202a, 0x202b,
23
+ 0x202c, 0x202d, 0x202e, 0x2060, 0x2061, 0x2062, 0x2063, 0x2064, 0x2066,
24
+ 0x2067, 0x2068, 0x2069, 0x206a, 0x206b, 0x206c, 0x206d, 0x206e, 0x206f,
25
+ 0xfeff, 0xfff9, 0xfffa, 0xfffb, 0x110bd, 0x110cd, 0x13430, 0x13431, 0x13432,
26
+ 0x13433, 0x13434, 0x13435, 0x13436, 0x13437, 0x13438, 0x13439, 0x1343a,
27
+ 0x1343b, 0x1343c, 0x1343d, 0x1343e, 0x1343f, 0x1bca0, 0x1bca1, 0x1bca2,
28
+ 0x1bca3, 0x1d173, 0x1d174, 0x1d175, 0x1d176, 0x1d177, 0x1d178, 0x1d179,
29
+ 0x1d17a, 0xe0001, 0xe0020, 0xe0021, 0xe0022, 0xe0023, 0xe0024, 0xe0025,
30
+ 0xe0026, 0xe0027, 0xe0028, 0xe0029, 0xe002a, 0xe002b, 0xe002c, 0xe002d,
31
+ 0xe002e, 0xe002f, 0xe0030, 0xe0031, 0xe0032, 0xe0033, 0xe0034, 0xe0035,
32
+ 0xe0036, 0xe0037, 0xe0038, 0xe0039, 0xe003a, 0xe003b, 0xe003c, 0xe003d,
33
+ 0xe003e, 0xe003f, 0xe0040, 0xe0041, 0xe0042, 0xe0043, 0xe0044, 0xe0045,
34
+ 0xe0046, 0xe0047, 0xe0048, 0xe0049, 0xe004a, 0xe004b, 0xe004c, 0xe004d,
35
+ 0xe004e, 0xe004f, 0xe0050, 0xe0051, 0xe0052, 0xe0053, 0xe0054, 0xe0055,
36
+ 0xe0056, 0xe0057, 0xe0058, 0xe0059, 0xe005a, 0xe005b, 0xe005c, 0xe005d,
37
+ 0xe005e, 0xe005f, 0xe0060, 0xe0061, 0xe0062, 0xe0063, 0xe0064, 0xe0065,
38
+ 0xe0066, 0xe0067, 0xe0068, 0xe0069, 0xe006a, 0xe006b, 0xe006c, 0xe006d,
39
+ 0xe006e, 0xe006f, 0xe0070, 0xe0071, 0xe0072, 0xe0073, 0xe0074, 0xe0075,
40
+ 0xe0076, 0xe0077, 0xe0078, 0xe0079, 0xe007a, 0xe007b, 0xe007c, 0xe007d,
41
+ 0xe007e, 0xe007f,
42
+ ]);
43
+ // Stryker restore all