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.
- package/LICENSE +202 -0
- package/README.md +217 -0
- package/SECURITY.md +31 -0
- package/THREAT-MODEL.md +173 -0
- package/bin/sanitize-cli.mjs +423 -0
- package/package.json +157 -0
- package/src/cf-charset.mjs +43 -0
- package/src/confusables.mjs +199 -0
- package/src/gates.mjs +71 -0
- package/src/html.mjs +2233 -0
- package/src/index.mjs +166 -0
- package/src/instructions.mjs +530 -0
- package/src/invisible.mjs +976 -0
- package/src/joining-type.mjs +616 -0
- package/src/layer1.mjs +177 -0
- package/src/output.mjs +788 -0
- package/src/prompt.mjs +154 -0
- package/src/rehydrate.mjs +646 -0
- package/src/standardized-variants.mjs +1335 -0
- package/src/view-map.mjs +354 -0
- package/types/cf-charset.d.mts +14 -0
- package/types/confusables.d.mts +85 -0
- package/types/gates.d.mts +37 -0
- package/types/html.d.mts +117 -0
- package/types/index.d.mts +33 -0
- package/types/instructions.d.mts +137 -0
- package/types/invisible.d.mts +98 -0
- package/types/joining-type.d.mts +22 -0
- package/types/layer1.d.mts +35 -0
- package/types/output.d.mts +195 -0
- package/types/prompt.d.mts +28 -0
- package/types/rehydrate.d.mts +54 -0
- package/types/standardized-variants.d.mts +22 -0
- package/types/view-map.d.mts +170 -0
|
@@ -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
|