agent-sanitizer 2.41.0 → 2.41.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.
@@ -386,6 +386,21 @@ export function lastStdinByteLength() {
386
386
  return lastStdinBytes;
387
387
  }
388
388
 
389
+ /**
390
+ * A hook run that received no event at all: stdin closed with zero bytes. Its
391
+ * own type, not the `SyntaxError` an empty string gets from `JSON.parse`,
392
+ * because the two are different faults with different fixes — a malformed
393
+ * payload is a sender that sent something wrong, an empty one is a hook wired to
394
+ * a channel that sent nothing, and a caller that cannot tell them apart reports
395
+ * the wrong cause and offers remedies for content it never received.
396
+ */
397
+ export class EmptyStdinError extends Error {
398
+ constructor() {
399
+ super("empty stdin: the hook received no payload");
400
+ this.name = "EmptyStdinError";
401
+ }
402
+ }
403
+
389
404
  /**
390
405
  * @param {number} [maxBytes] cap before aborting (overridable for tests)
391
406
  * @returns {Promise<any>}
@@ -393,6 +408,8 @@ export function lastStdinByteLength() {
393
408
  export async function readStdinJson(maxBytes = MAX_STDIN_BYTES) {
394
409
  const buf = await readAllBounded(process.stdin, maxBytes);
395
410
  lastStdinBytes = buf.length;
411
+ // Before the parse, so the empty case never renders as malformed JSON.
412
+ if (buf.length === 0) throw new EmptyStdinError();
396
413
  return JSON.parse(buf.toString());
397
414
  }
398
415
 
@@ -20,6 +20,7 @@
20
20
  import { readFileSync } from "node:fs";
21
21
  import {
22
22
  emitHookResponse,
23
+ EmptyStdinError,
23
24
  HookEvent,
24
25
  isMain,
25
26
  lazyImport,
@@ -82,6 +83,30 @@ function faultLine(ctx) {
82
83
  );
83
84
  }
84
85
 
86
+ /**
87
+ * The parts for a run that received no event at all, or null when the fault is
88
+ * anything else. Zero bytes on stdin means no InstructionsLoaded payload was
89
+ * delivered, so no file was named and none went unscanned: the fault is in how
90
+ * the hook was INVOKED, not in a scan. Both postures render it the same way and
91
+ * neither arms the tool-call gate — that gate asks the user to clear something
92
+ * about this project's instruction files, and this fault says nothing about
93
+ * them, so arming it blocks the next tool call over a hook that was handed no
94
+ * event.
95
+ * @param {import("./lib/hook-fault.mjs").FaultContext} ctx
96
+ * @returns {import("./lib/hook-fault.mjs").FaultParts | null}
97
+ */
98
+ function emptyPayloadParts(ctx) {
99
+ if (!(ctx.err instanceof EmptyStdinError)) return null;
100
+ return {
101
+ stderr:
102
+ `${HOOK_NAME} hook error: ${ctx.message}. No instruction file was named, so nothing ` +
103
+ "was scanned and nothing was left unguarded. Check how this hook is invoked: it reads " +
104
+ "its InstructionsLoaded event as JSON on stdin, and one wired to a channel that " +
105
+ "delivers nothing scans nothing for the whole session.\n",
106
+ exitCode: 1,
107
+ };
108
+ }
109
+
85
110
  // This hook's entry in the one posture table (lib/hook-fault.mjs). Like
86
111
  // scan-invisible-chars it has no stdout verdict channel — InstructionsLoaded
87
112
  // cannot block, and its exit code is ignored — so both arms are stated
@@ -90,15 +115,17 @@ function faultLine(ctx) {
90
115
  registerFaultPolicy(HOOK_NAME, {
91
116
  event: HookEvent.INSTRUCTIONS_LOADED,
92
117
  guarded: "a loaded instruction file",
93
- open: (ctx) => ({
94
- stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
95
- exitCode: 1,
96
- }),
97
- closed: (ctx) => ({
98
- stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
99
- exitCode: 1,
100
- armAlert: true,
101
- }),
118
+ open: (ctx) =>
119
+ emptyPayloadParts(ctx) ?? {
120
+ stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
121
+ exitCode: 1,
122
+ },
123
+ closed: (ctx) =>
124
+ emptyPayloadParts(ctx) ?? {
125
+ stderr: `${faultLine(ctx)} Arming the tool-call gate (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
126
+ exitCode: 1,
127
+ armAlert: true,
128
+ },
102
129
  });
103
130
 
104
131
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.41.0",
3
+ "version": "2.41.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": {
@@ -485,6 +485,17 @@ export const DISABLED_HOOKS_ENV: "AGENT_SANITIZER_DISABLED_HOOKS";
485
485
  * and take its own fail-closed output down with it.
486
486
  */
487
487
  export const MAX_STDIN_BYTES: number;
488
+ /**
489
+ * A hook run that received no event at all: stdin closed with zero bytes. Its
490
+ * own type, not the `SyntaxError` an empty string gets from `JSON.parse`,
491
+ * because the two are different faults with different fixes — a malformed
492
+ * payload is a sender that sent something wrong, an empty one is a hook wired to
493
+ * a channel that sent nothing, and a caller that cannot tell them apart reports
494
+ * the wrong cause and offers remedies for content it never received.
495
+ */
496
+ export class EmptyStdinError extends Error {
497
+ constructor();
498
+ }
488
499
  /**
489
500
  * The remedy {@link missingPackageMessage} states when the host does not supply
490
501
  * one of its own. A host whose install has a specific entry point (a setup