agent-sanitizer 2.19.0 → 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.
@@ -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
- /** @param {Record<string, unknown>} req @param {string} key */
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
- if (typeof req[key] !== "string")
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(req.text, {
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.text,
110
- {
111
- html: Boolean(req.html),
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(req.text);
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(req.globs) ||
136
- req.globs.some((g) => typeof g !== "string")
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(req.globs, opts) };
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(req.path) };
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
- const op = request.op ?? "sanitize";
167
- const run = Object.prototype.hasOwnProperty.call(OPS, op) ? OPS[op] : null;
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: err?.message ?? String(err) });
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: ${err?.message ?? String(err)}\n`);
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.0",
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": "c8 node --test",
211
- "coverage": "c8 node --test",
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": "stryker run",
217
+ "test:mutation": "node scripts/mutate.mjs",
218
218
  "format": "prettier --write .",
219
219
  "format:check": "prettier --check ."
220
220
  }