agent-sanitizer 2.19.1 → 2.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/sanitize-cli.mjs +62 -27
- package/package.json +4 -4
package/bin/sanitize-cli.mjs
CHANGED
|
@@ -70,8 +70,16 @@ function maxInputBytes() {
|
|
|
70
70
|
return Math.floor(parsed);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** The one-line reason reported for a caught throw. A thrown non-Error that
|
|
74
|
+
* still carries a `message` keeps reporting it (the historical behaviour);
|
|
75
|
+
* anything else stringifies.
|
|
76
|
+
* @param {unknown} err */
|
|
77
|
+
const errorMessage = (err) =>
|
|
78
|
+
/** @type {{ message?: string }} */ (err)?.message ?? String(err);
|
|
79
|
+
|
|
73
80
|
/** 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.
|
|
81
|
+
* and the env var so a caller can act on it.
|
|
82
|
+
* @param {string} text */
|
|
75
83
|
function enforceSizeLimit(text) {
|
|
76
84
|
const limit = maxInputBytes();
|
|
77
85
|
const size = Buffer.byteLength(text, "utf8");
|
|
@@ -82,43 +90,49 @@ function enforceSizeLimit(text) {
|
|
|
82
90
|
);
|
|
83
91
|
}
|
|
84
92
|
|
|
85
|
-
/**
|
|
93
|
+
/** Read a required string field, throwing when it is absent or the wrong type.
|
|
94
|
+
* Returns the value (rather than only asserting) so the caller carries the
|
|
95
|
+
* narrowed `string` forward instead of re-reading an untyped bag.
|
|
96
|
+
* @param {Record<string, unknown>} req @param {string} key
|
|
97
|
+
* @returns {string} */
|
|
86
98
|
function requireString(req, key) {
|
|
87
|
-
|
|
99
|
+
const value = req[key];
|
|
100
|
+
if (typeof value !== "string")
|
|
88
101
|
throw new Error(`request.${key} must be a string`);
|
|
102
|
+
return value;
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
/** Operations the CLI exposes. Each takes the parsed request, returns the JSON
|
|
92
106
|
* payload object. Non-`sanitize` modules are imported lazily so a caller that
|
|
93
107
|
* only ever sanitizes never loads prompt/output/instructions code. */
|
|
94
108
|
const OPS = {
|
|
109
|
+
/** @param {Record<string, unknown>} req */
|
|
95
110
|
async sanitize(req) {
|
|
96
|
-
requireString(req, "text");
|
|
97
|
-
const { cleaned, found, warnings } = await sanitize(
|
|
111
|
+
const text = requireString(req, "text");
|
|
112
|
+
const { cleaned, found, warnings } = await sanitize(text, {
|
|
98
113
|
html: Boolean(req.html),
|
|
99
114
|
});
|
|
100
115
|
return { cleaned, found, warnings };
|
|
101
116
|
},
|
|
102
117
|
|
|
118
|
+
/** @param {Record<string, unknown>} req */
|
|
103
119
|
async sanitizeText(req) {
|
|
104
|
-
requireString(req, "text");
|
|
120
|
+
const text = requireString(req, "text");
|
|
105
121
|
// Layers 1–3 only: redact (Layer 4) and filterInjection (Layer 5) are
|
|
106
122
|
// injected JS callbacks with no wire form, so they're never set here.
|
|
107
123
|
const { sanitizeText } = await import("../src/output.mjs");
|
|
108
|
-
const { cleaned, warnings, modified, sgrNote } = await sanitizeText(
|
|
109
|
-
req.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
exfilScan: Boolean(req.exfilScan),
|
|
113
|
-
},
|
|
114
|
-
);
|
|
124
|
+
const { cleaned, warnings, modified, sgrNote } = await sanitizeText(text, {
|
|
125
|
+
html: Boolean(req.html),
|
|
126
|
+
exfilScan: Boolean(req.exfilScan),
|
|
127
|
+
});
|
|
115
128
|
return { cleaned, warnings, modified, sgrNote };
|
|
116
129
|
},
|
|
117
130
|
|
|
131
|
+
/** @param {Record<string, unknown>} req */
|
|
118
132
|
async classifyPrompt(req) {
|
|
119
|
-
requireString(req, "text");
|
|
133
|
+
const text = requireString(req, "text");
|
|
120
134
|
const { classifyPrompt } = await import("../src/prompt.mjs");
|
|
121
|
-
return classifyPrompt(
|
|
135
|
+
return classifyPrompt(text);
|
|
122
136
|
},
|
|
123
137
|
|
|
124
138
|
// SECURITY (R8): `scanInstructionFiles` and `cleanFile` take filesystem
|
|
@@ -130,10 +144,12 @@ const OPS = {
|
|
|
130
144
|
// this CLI's stdin to untrusted/model-controlled input for these ops. If you
|
|
131
145
|
// must accept untrusted callers, add opt-in root confinement (reject paths
|
|
132
146
|
// that resolve outside an allow-listed root) before exposing them.
|
|
147
|
+
/** @param {Record<string, unknown>} req */
|
|
133
148
|
async scanInstructionFiles(req) {
|
|
149
|
+
const globs = req.globs;
|
|
134
150
|
if (
|
|
135
|
-
!Array.isArray(
|
|
136
|
-
|
|
151
|
+
!Array.isArray(globs) ||
|
|
152
|
+
globs.some((/** @type {unknown} */ g) => typeof g !== "string")
|
|
137
153
|
)
|
|
138
154
|
throw new Error("request.globs must be an array of strings");
|
|
139
155
|
// Fail loud on a present-but-non-string cwd rather than silently dropping it
|
|
@@ -143,13 +159,14 @@ const OPS = {
|
|
|
143
159
|
throw new Error("request.cwd must be a string");
|
|
144
160
|
const { scanInstructionFiles } = await import("../src/instructions.mjs");
|
|
145
161
|
const opts = typeof req.cwd === "string" ? { cwd: req.cwd } : {};
|
|
146
|
-
return { findings: scanInstructionFiles(
|
|
162
|
+
return { findings: scanInstructionFiles(globs, opts) };
|
|
147
163
|
},
|
|
148
164
|
|
|
165
|
+
/** @param {Record<string, unknown>} req */
|
|
149
166
|
async cleanFile(req) {
|
|
150
|
-
requireString(req, "path");
|
|
167
|
+
const path = requireString(req, "path");
|
|
151
168
|
const { cleanFile } = await import("../src/instructions.mjs");
|
|
152
|
-
return { changed: cleanFile(
|
|
169
|
+
return { changed: cleanFile(path) };
|
|
153
170
|
},
|
|
154
171
|
};
|
|
155
172
|
|
|
@@ -162,9 +179,13 @@ const OPS = {
|
|
|
162
179
|
*/
|
|
163
180
|
async function handle(payload) {
|
|
164
181
|
enforceSizeLimit(payload);
|
|
165
|
-
const request = JSON.parse(payload);
|
|
166
|
-
|
|
167
|
-
|
|
182
|
+
const request = /** @type {Record<string, unknown>} */ (JSON.parse(payload));
|
|
183
|
+
// Stringified so a non-string `op` (a number, an object) still reaches the
|
|
184
|
+
// hasOwnProperty guard and the same "unknown op" error it always did.
|
|
185
|
+
const op = String(request.op ?? "sanitize");
|
|
186
|
+
const run = Object.prototype.hasOwnProperty.call(OPS, op)
|
|
187
|
+
? OPS[/** @type {keyof typeof OPS} */ (op)]
|
|
188
|
+
: null;
|
|
168
189
|
if (!run) throw new Error(`unknown op: ${op}`);
|
|
169
190
|
return JSON.stringify(await run(request));
|
|
170
191
|
}
|
|
@@ -189,6 +210,11 @@ async function readAll(stream) {
|
|
|
189
210
|
return text;
|
|
190
211
|
}
|
|
191
212
|
|
|
213
|
+
/**
|
|
214
|
+
* One unit of the worker's one-response-per-input-line framing.
|
|
215
|
+
* @typedef {{ kind: "line", text: string } | { kind: "oversize" }} SplitEvent
|
|
216
|
+
*/
|
|
217
|
+
|
|
192
218
|
/**
|
|
193
219
|
* Streaming newline-splitter that never buffers a line past the byte `limit`.
|
|
194
220
|
*
|
|
@@ -211,6 +237,7 @@ async function readAll(stream) {
|
|
|
211
237
|
* the one-response-per-input-line framing holds even for a dropped line.
|
|
212
238
|
*
|
|
213
239
|
* @param {number} limit per-line byte cap (`maxInputBytes()`)
|
|
240
|
+
* @returns {((chunk: Buffer) => SplitEvent[]) & { end: () => SplitEvent[] }}
|
|
214
241
|
*/
|
|
215
242
|
function createLineSplitter(limit) {
|
|
216
243
|
// The bytes of the current line are held as a LIST of chunk slices plus their
|
|
@@ -240,7 +267,8 @@ function createLineSplitter(limit) {
|
|
|
240
267
|
return buf;
|
|
241
268
|
};
|
|
242
269
|
|
|
243
|
-
/** Strip one trailing `\r` so CRLF input frames identically to LF.
|
|
270
|
+
/** Strip one trailing `\r` so CRLF input frames identically to LF.
|
|
271
|
+
* @param {Buffer} buf */
|
|
244
272
|
const toLine = (buf) => {
|
|
245
273
|
const stripCr = buf.length > 0 && buf[buf.length - 1] === 0x0d;
|
|
246
274
|
return buf.toString("utf8", 0, stripCr ? buf.length - 1 : buf.length);
|
|
@@ -250,6 +278,7 @@ function createLineSplitter(limit) {
|
|
|
250
278
|
// into the pending list, flipping to `discarding` if it would breach the cap.
|
|
251
279
|
// Applies identically to a newline-terminated segment and to the unterminated
|
|
252
280
|
// tail, so an oversize line is caught WITHIN a chunk, not only at its edge.
|
|
281
|
+
/** @param {Buffer} segment */
|
|
253
282
|
const accumulate = (segment) => {
|
|
254
283
|
if (discarding) return;
|
|
255
284
|
if (pendingLen + segment.length > limit) {
|
|
@@ -263,8 +292,11 @@ function createLineSplitter(limit) {
|
|
|
263
292
|
}
|
|
264
293
|
};
|
|
265
294
|
|
|
266
|
-
/** Feed one chunk, returning the events it completes (newline-terminated).
|
|
295
|
+
/** Feed one chunk, returning the events it completes (newline-terminated).
|
|
296
|
+
* @param {Buffer} chunk
|
|
297
|
+
* @returns {SplitEvent[]} */
|
|
267
298
|
const push = (chunk) => {
|
|
299
|
+
/** @type {SplitEvent[]} */
|
|
268
300
|
const events = [];
|
|
269
301
|
let start = 0;
|
|
270
302
|
for (let i = 0; i < chunk.length; i++) {
|
|
@@ -287,6 +319,7 @@ function createLineSplitter(limit) {
|
|
|
287
319
|
* Flush at EOF. A final line with no trailing `\n` is still a request, so it
|
|
288
320
|
* gets a response — matching `readline`, which emits its last line on `close`.
|
|
289
321
|
* An empty tail (stream ended on a `\n`, or was empty) yields nothing.
|
|
322
|
+
* @returns {SplitEvent[]}
|
|
290
323
|
*/
|
|
291
324
|
push.end = () => {
|
|
292
325
|
if (discarding) {
|
|
@@ -301,6 +334,7 @@ function createLineSplitter(limit) {
|
|
|
301
334
|
return push;
|
|
302
335
|
}
|
|
303
336
|
|
|
337
|
+
/** @param {number} limit */
|
|
304
338
|
const OVERSIZE_ERROR = (limit) =>
|
|
305
339
|
JSON.stringify({
|
|
306
340
|
error:
|
|
@@ -323,6 +357,7 @@ async function runWorker() {
|
|
|
323
357
|
// "request too large" error a one-shot caller sees. `response` never holds a
|
|
324
358
|
// newline: `JSON.stringify` of the result or of a one-key error object is
|
|
325
359
|
// single-line, so the one-line-per-request framing holds.
|
|
360
|
+
/** @param {SplitEvent} event */
|
|
326
361
|
const respond = async (event) => {
|
|
327
362
|
if (event.kind === "oversize") {
|
|
328
363
|
process.stdout.write(`${OVERSIZE_ERROR(limit)}\n`);
|
|
@@ -332,7 +367,7 @@ async function runWorker() {
|
|
|
332
367
|
try {
|
|
333
368
|
response = await handle(event.text);
|
|
334
369
|
} catch (err) {
|
|
335
|
-
response = JSON.stringify({ error:
|
|
370
|
+
response = JSON.stringify({ error: errorMessage(err) });
|
|
336
371
|
}
|
|
337
372
|
process.stdout.write(`${response}\n`);
|
|
338
373
|
};
|
|
@@ -353,7 +388,7 @@ async function runOneShot() {
|
|
|
353
388
|
try {
|
|
354
389
|
response = await handle(await readAll(process.stdin));
|
|
355
390
|
} catch (err) {
|
|
356
|
-
process.stderr.write(`sanitize CLI: ${
|
|
391
|
+
process.stderr.write(`sanitize CLI: ${errorMessage(err)}\n`);
|
|
357
392
|
process.exitCode = 1;
|
|
358
393
|
return;
|
|
359
394
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.19.
|
|
3
|
+
"version": "2.19.2",
|
|
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": {
|
|
@@ -207,14 +207,14 @@
|
|
|
207
207
|
"unist-util-visit": "5.1.0"
|
|
208
208
|
},
|
|
209
209
|
"scripts": {
|
|
210
|
-
"test": "
|
|
211
|
-
"coverage": "
|
|
210
|
+
"test": "node scripts/coverage.mjs",
|
|
211
|
+
"coverage": "node scripts/coverage.mjs",
|
|
212
212
|
"check": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
|
|
213
213
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.hooks.json --noEmit",
|
|
214
214
|
"build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
|
|
215
215
|
"gen:joining-type": "node scripts/gen-joining-type.mjs",
|
|
216
216
|
"lint": "eslint .",
|
|
217
|
-
"test:mutation": "
|
|
217
|
+
"test:mutation": "node scripts/mutate.mjs",
|
|
218
218
|
"format": "prettier --write .",
|
|
219
219
|
"format:check": "prettier --check ."
|
|
220
220
|
}
|