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
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
|
+
}
|