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/src/prompt.mjs ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * User-prompt verdict: classify a submitted prompt as pass / pass-with-note /
3
+ * block on payload-capable invisible Unicode and ANSI escapes.
4
+ *
5
+ * A prompt pasted from a tampered web page can carry tag characters or
6
+ * zero-width sequences the model reads but the user cannot see, and a
7
+ * prompt-submission channel typically cannot rewrite the prompt in place — so
8
+ * the only way to neutralize a payload is to block. This is the pure decision;
9
+ * a host wraps it in whatever its agent's prompt-submission hook expects.
10
+ *
11
+ * One carve-out: a prompt whose only escape content is SGR color/style codes
12
+ * (`ESC [ params m`) passes with a note instead of blocking. Pasting colored
13
+ * terminal output (test runs, build logs) is the single most common debugging
14
+ * action, and SGR is display-only by the ECMA-48 grammar — it cannot move the
15
+ * cursor, erase the screen, or carry an OSC payload. Anything beyond SGR still
16
+ * blocks, as do the invisible-char thresholds.
17
+ */
18
+ import {
19
+ CHECKS,
20
+ CATEGORY,
21
+ CATEGORY_LABELS,
22
+ LONG_RUN_RE,
23
+ LONG_RUN_THRESHOLD,
24
+ SCATTERED_THRESHOLD,
25
+ countPayloadInvisible,
26
+ stripInvisible,
27
+ isSgrOnly,
28
+ } from "./invisible.mjs";
29
+ import { stripAnsiFully } from "./layer1.mjs";
30
+
31
+ // Every raw ANSI control a prompt can carry: 7-bit ESC (U+001B) and the entire
32
+ // 8-bit C1 block (U+0080-U+009F). Gating on ESC alone -- or on only CSI/OSC --
33
+ // is blind to a pure-C1 sequence whose escape content is, e.g., `U+009B 2J`
34
+ // (CSI erase), `U+009D 0;...BEL` (OSC), or the string introducers DCS (U+0090),
35
+ // SOS (U+0098), PM (U+009E), APC (U+009F): Layer 1 strips it, dropping the
36
+ // invisible count to zero, so the prompt reads clean and passes. This gate must
37
+ // match Layer 1's residual sweep (CONTROL_INTRODUCER_RE) exactly -- no raw C1
38
+ // control belongs in a legitimate prompt (the SGR color carve-out is applied
39
+ // separately, after SGR removal), so the whole block is gated, not a hand-picked
40
+ // subset that lets DCS/SOS/PM/APC through.
41
+ // eslint-disable-next-line no-control-regex -- the raw control introducers are exactly what we detect
42
+ const ANSI_INTRODUCER = /[\u001b\u0080-\u009f]/;
43
+
44
+ /**
45
+ * True when every ANSI introducer in `prompt` belongs to a display-only SGR
46
+ * color sequence -- the note carve-out's precondition. `isSgrOnly` already tests
47
+ * the SGR-stripped prompt against the WHOLE C1 control block (U+0080-U+009F,
48
+ * which includes the C1 OSC introducer U+009D and DCS/SOS/PM/APC) plus the 7-bit
49
+ * ESC, so a residual C1-OSC or any non-SGR escape already denies it — no
50
+ * separate re-check is needed (an earlier `&& !ANSI_INTRODUCER.test(...)` here
51
+ * was an exact duplicate of that gate over the same stripped string).
52
+ * @param {string} prompt
53
+ * @returns {boolean}
54
+ */
55
+ function isSgrColorOnly(prompt) {
56
+ return isSgrOnly(prompt);
57
+ }
58
+
59
+ /**
60
+ * Human-facing block reason: what was detected, the thresholds, a code-point
61
+ * sample of the long run (if any), and how to recover.
62
+ * @param {string[]} categories
63
+ * @param {number} invisibleCount
64
+ * @param {string | null} longRunSample
65
+ * @returns {string}
66
+ */
67
+ export function formatReason(categories, invisibleCount, longRunSample) {
68
+ const parts = [
69
+ `Detected: ${categories.join(", ")}.`,
70
+ `Invisible char count: ${invisibleCount} (long-run threshold: ${LONG_RUN_THRESHOLD}, scattered threshold: ${SCATTERED_THRESHOLD}).`,
71
+ ];
72
+ if (longRunSample) {
73
+ const cps = [...longRunSample]
74
+ .slice(0, 16)
75
+ .map(
76
+ (ch) =>
77
+ "U+" +
78
+ /** @type {number} */ (ch.codePointAt(0))
79
+ .toString(16)
80
+ .toUpperCase()
81
+ .padStart(4, "0"),
82
+ )
83
+ .join(" ");
84
+ parts.push(`Long-run sample (first 16 code points): ${cps}.`);
85
+ }
86
+ parts.push(
87
+ "Resubmit the prompt with invisible/ANSI characters removed. If you pasted this from a webpage, the source may be carrying a prompt-injection payload.",
88
+ );
89
+ return parts.join(" ");
90
+ }
91
+
92
+ /**
93
+ * Pure verdict for a user prompt: pass through, pass with an SGR note, or
94
+ * block. `strip` (the ANSI stripper, defaulting to the package's
95
+ * {@link stripAnsiFully}) runs on every prompt so invisibles smuggled *inside*
96
+ * an ANSI sequence (an OSC string) are stripped before the invisible-char
97
+ * thresholds are counted; it is injectable so a host can substitute its own
98
+ * stripper or exercise the fail-closed path.
99
+ * @param {string} prompt
100
+ * @param {(s: string) => string} [strip]
101
+ * @returns {{action:"pass"} | {action:"note"} | {action:"block", reason:string}}
102
+ */
103
+ export function classifyPrompt(prompt, strip = stripAnsiFully) {
104
+ if (!prompt) return { action: "pass" };
105
+
106
+ const hasAnsi = ANSI_INTRODUCER.test(prompt);
107
+ const deAnsi = strip(prompt);
108
+
109
+ const longRunSample = deAnsi.match(LONG_RUN_RE)?.[0] ?? null;
110
+ // Count only PAYLOAD invisibles for the scatter gate: ZWNJ/ZWJ (and emoji
111
+ // VS16) that do real rendering work are excluded, so a legitimately
112
+ // joiner-dense multilingual prompt (formal Persian, an emoji ZWJ sequence) is
113
+ // not blocked by sheer joiner count. This mirrors carveStrip's own
114
+ // payloadInvis < SCATTERED_THRESHOLD gate so the block and strip layers agree.
115
+ const payloadInvisible = countPayloadInvisible(deAnsi);
116
+ // Preserved-joiner covert channel (O3). countPayloadInvisible EXCLUDES the
117
+ // ZWNJ/ZWJ (and emoji selectors) that do real rendering work, so a channel
118
+ // built entirely from MEANINGFUL joiners — an attacker alternates
119
+ // `letter joiner letter joiner …` so every joiner sits between two cursive
120
+ // letters — counts as ZERO here and would pass, even though the strip layer
121
+ // (carveStrip) only PRESERVES joiners up to a per-document budget
122
+ // (TOTAL_PRESERVED_JOINER_BUDGET / CONSECUTIVE_JOINER_CAP) and strips the
123
+ // surplus as payload. A prompt channel cannot strip, only block, so mirror
124
+ // that budget by counting the joiners the strip layer WOULD remove — delegated
125
+ // to stripInvisible (the SSOT) rather than re-deriving the budget here, which
126
+ // would risk drift — and fold that surplus into the count the scatter gate
127
+ // sees. A leading BOM is preserved by the strip but counted by
128
+ // countPayloadInvisible, so the difference can go slightly negative; clamp it.
129
+ const surplusPreservedJoiners = Math.max(
130
+ 0,
131
+ [...deAnsi].length - [...stripInvisible(deAnsi)].length - payloadInvisible,
132
+ );
133
+ const invisibleCount = payloadInvisible + surplusPreservedJoiners;
134
+ const invisiblesBelowThreshold =
135
+ longRunSample === null && invisibleCount < SCATTERED_THRESHOLD;
136
+
137
+ if (!hasAnsi && invisiblesBelowThreshold) return { action: "pass" };
138
+
139
+ // Display-only color codes in an otherwise clean prompt: pass with a note
140
+ // instead of blocking, so pasted colored logs remain usable.
141
+ if (hasAnsi && invisiblesBelowThreshold && isSgrColorOnly(prompt))
142
+ return { action: "note" };
143
+
144
+ // CHECKS pairs a machine-readable category code with its detector; map each
145
+ // matched code to its human label for the user-facing block reason.
146
+ const categories = CHECKS.filter(([, re]) => deAnsi.search(re) !== -1).map(
147
+ ([code]) => CATEGORY_LABELS[code],
148
+ );
149
+ if (hasAnsi) categories.push(CATEGORY_LABELS[CATEGORY.ANSI]);
150
+ return {
151
+ action: "block",
152
+ reason: formatReason(categories, invisibleCount, longRunSample),
153
+ };
154
+ }