dsh-dlp 0.2.0 → 0.4.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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The `ask` tier: files whose contents decide how the agent, the editor or CI
3
+ * behaves next time, and the one config *key* whose value redirects a
4
+ * credential.
5
+ *
6
+ * This tier is deliberately **not** on the guard floor, and that is the whole
7
+ * design. `ctx.tools.guard()` has no `ask` arm and cannot be overridden, so a
8
+ * rule that lands there must be one a developer never legitimately trips. A
9
+ * developer asks the agent to edit `CLAUDE.md`, add a `.github/workflows`
10
+ * job or extend `.vscode/settings.json` constantly. Putting those on an
11
+ * unoverridable floor produces a plugin that gets uninstalled, which removes
12
+ * the floor as well.
13
+ *
14
+ * The cost is stated rather than hidden: `tools/pre-execute` is neutralizable.
15
+ * A listener registered ahead of this one can return without calling `next()`
16
+ * and this tier never runs. Only the guard floor is order-independent.
17
+ * @module dsh-dlp/config-writes
18
+ */
19
+ import { isReadOnlyTool, normalizeCandidatePath, pathArguments } from "./paths.js";
20
+ import { nestedStrings } from "./redaction.js";
21
+ /**
22
+ * Paths whose contents change future behaviour, and the config key whose value
23
+ * redirects credentials.
24
+ *
25
+ * Every path here was used in the wild. The Miasma worm wrote `SessionStart`
26
+ * hooks into `.claude/settings.json` and `.gemini/settings.json`, an
27
+ * always-apply `.cursor/rules/setup.mdc`, a `folderOpen` task into
28
+ * `.vscode/tasks.json`, and a hijacked `npm test` into `Azure/durabletask`;
29
+ * GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples
30
+ * over it, 39 of them inside 38 seconds. See also CVE-2025-53773,
31
+ * CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
32
+ * CVE-2025-59041.
33
+ *
34
+ * The rules match by name, never by what is on disk, so a file the call is
35
+ * about to *create* is matched exactly like one it would change:
36
+ * CVE-2026-25725 worked precisely because the path did not exist yet and was
37
+ * therefore writable without any prompt.
38
+ */
39
+ export const CONFIG_WRITE_RULES = [
40
+ {
41
+ id: 'dsh-dlp/config-agent-settings',
42
+ version: 1,
43
+ match: 'path',
44
+ pattern: /(^|\/)\.(claude|gemini|codex|cursor|windsurf|continue)\/settings[^/]*\.json$/i,
45
+ effect: 'agent settings, which can register hooks that run on every future session',
46
+ },
47
+ {
48
+ id: 'dsh-dlp/config-agent-hooks',
49
+ version: 1,
50
+ match: 'path',
51
+ pattern: /(^|\/)\.(claude|gemini|codex|windsurf|continue)\/hooks(\/|$)/i,
52
+ effect: 'an agent hook, which runs on a session event without the model asking for it',
53
+ },
54
+ {
55
+ id: 'dsh-dlp/config-agent-instructions',
56
+ version: 1,
57
+ match: 'path',
58
+ pattern: /(^|\/)(CLAUDE|AGENTS|GEMINI|\.cursorrules|\.windsurfrules)(\.md)?$/i,
59
+ effect: 'standing instructions every future session in this repository reads',
60
+ },
61
+ {
62
+ id: 'dsh-dlp/config-agent-rules',
63
+ version: 1,
64
+ match: 'path',
65
+ pattern: /(^|\/)\.(cursor|windsurf|continue)\/rules(\/|$)/i,
66
+ effect: 'an always-apply rules file every future session in this repository reads',
67
+ },
68
+ {
69
+ id: 'dsh-dlp/config-mcp-manifest',
70
+ version: 1,
71
+ match: 'path',
72
+ pattern: /(^|\/)\.mcp\.json$/i,
73
+ effect: 'the MCP manifest, which decides which servers the agent starts and with what environment',
74
+ },
75
+ {
76
+ id: 'dsh-dlp/config-editor-tasks',
77
+ version: 1,
78
+ match: 'path',
79
+ pattern: /(^|\/)\.vscode\/(settings|tasks|launch)\.json$/i,
80
+ effect: 'editor configuration, which can run a task the moment the folder is opened',
81
+ },
82
+ {
83
+ id: 'dsh-dlp/config-git',
84
+ version: 1,
85
+ match: 'path',
86
+ pattern: /(^|\/)\.git\/(config$|hooks(\/|$))/i,
87
+ effect: 'git configuration or a git hook, which runs on the next commit, checkout or push',
88
+ },
89
+ {
90
+ id: 'dsh-dlp/config-git-hooks-managed',
91
+ version: 1,
92
+ match: 'path',
93
+ pattern: /(^|\/)\.husky(\/|$)/i,
94
+ effect: 'a managed git hook, which runs on the next commit or push',
95
+ },
96
+ {
97
+ id: 'dsh-dlp/config-ci-workflow',
98
+ version: 1,
99
+ match: 'path',
100
+ pattern: /(^|\/)\.(github\/workflows|gitlab-ci\.yml|circleci)(\/|$)/i,
101
+ effect: 'a CI workflow, which runs on the shared runner with the repository\'s secrets',
102
+ },
103
+ {
104
+ id: 'dsh-dlp/config-shell-rc',
105
+ version: 1,
106
+ match: 'path',
107
+ pattern: /(^|\/)(\.bashrc|\.bash_profile|\.bash_login|\.bash_logout|\.profile|\.zshrc|\.zprofile|\.zshenv|\.zlogin|\.kshrc|config\.fish)$/i,
108
+ effect: 'a shell startup file, which runs on every future shell this agent opens',
109
+ },
110
+ {
111
+ id: 'dsh-dlp/config-harness-bundle',
112
+ version: 1,
113
+ match: 'path',
114
+ pattern: /(^|\/)cordis[^/]*\.ya?ml$/i,
115
+ effect: 'a harness bundle manifest, which decides which plugins load',
116
+ },
117
+ // CVE-2026-21852: a repo-local settings file setting `ANTHROPIC_BASE_URL`
118
+ // sends the user's own API key to whatever host it names. This is neither a
119
+ // path nor a secret — it is a key whose *value* redirects a credential — so
120
+ // it is matched against what would be written rather than against where.
121
+ {
122
+ id: 'dsh-dlp/config-api-base-url',
123
+ version: 1,
124
+ match: 'content',
125
+ pattern: /\b[A-Z][A-Z0-9_]*(?:_BASE_URL|_API_BASE)\b["']?\s*[=:]\s*["']?\s*https?:\/\//,
126
+ effect: 'a provider base URL, which sends the credential for that provider to whatever host it names',
127
+ },
128
+ ];
129
+ /**
130
+ * Argument keys whose values are the bytes a call would write.
131
+ *
132
+ * Deliberately separate from the floor's path-typed keys: the floor must never
133
+ * run its path table over file content, and this tier must run its one content
134
+ * rule over nothing else.
135
+ */
136
+ export const CONTENT_ARGUMENT_KEYS = new Set([
137
+ 'content', 'contents', 'text', 'file_text', 'fileText',
138
+ 'new_string', 'newString', 'new_str', 'replacement', 'body',
139
+ ]);
140
+ /**
141
+ * The strings one call would write.
142
+ * @param args - the pending call's parsed arguments.
143
+ * @returns every string under a content-typed key, at any depth.
144
+ */
145
+ export function contentArguments(args) {
146
+ const found = [];
147
+ const walk = (node) => {
148
+ if (Array.isArray(node)) {
149
+ for (const item of node)
150
+ walk(item);
151
+ return;
152
+ }
153
+ if (typeof node !== 'object' || node === null)
154
+ return;
155
+ for (const [key, value] of Object.entries(node)) {
156
+ if (CONTENT_ARGUMENT_KEYS.has(key))
157
+ found.push(...nestedStrings(value));
158
+ else
159
+ walk(value);
160
+ }
161
+ };
162
+ walk(args);
163
+ return found;
164
+ }
165
+ /**
166
+ * Prompt text for one behaviour-changing write.
167
+ *
168
+ * The path is not quoted, for the same reason the floor never quotes one: this
169
+ * string is model-visible and a path carries tenant and customer names. What
170
+ * the user needs in order to answer is which tool, which rule, and what the
171
+ * file does.
172
+ */
173
+ function configWriteReason(toolName, rule) {
174
+ return `dsh-dlp is asking before ${JSON.stringify(toolName)} writes ${rule.effect} (rule ${rule.id}). `
175
+ + 'This kind of file changes what happens on a later session, commit or CI run rather than now, so it is worth '
176
+ + 'one look. Approve it if you asked for this change; decline it if you did not.';
177
+ }
178
+ /**
179
+ * Decide whether one call should be confirmed by a human.
180
+ *
181
+ * Only calls that can change something are examined: a tool
182
+ * {@link isReadOnlyTool} classifies as query-only cannot write a hook. Shell
183
+ * command lines are deliberately **not** tokenised here, unlike in the floor:
184
+ * a command line cannot be told apart from a read of the same path, and
185
+ * prompting on `cat .github/workflows/ci.yml` is exactly the false positive
186
+ * that gets a tier switched off. A shell redirection into one of these files
187
+ * is therefore not covered, which README.md says beside the feature.
188
+ * @param exec - the pending call.
189
+ * @param rules - the rule table; defaults to {@link CONFIG_WRITE_RULES}.
190
+ * @returns the finding, or `undefined` to leave the call alone.
191
+ */
192
+ export function evaluateConfigWrite(exec, rules = CONFIG_WRITE_RULES) {
193
+ if (isReadOnlyTool(exec.name))
194
+ return undefined;
195
+ const targets = pathArguments(exec.arguments).filter(argument => !argument.shell);
196
+ for (const rule of rules) {
197
+ if (rule.match !== 'path')
198
+ continue;
199
+ for (const target of targets) {
200
+ if (rule.pattern.test(normalizeCandidatePath(target.text))) {
201
+ return { rule, reason: configWriteReason(exec.name, rule) };
202
+ }
203
+ }
204
+ }
205
+ const written = contentArguments(exec.arguments);
206
+ for (const rule of rules) {
207
+ if (rule.match !== 'content')
208
+ continue;
209
+ for (const text of written) {
210
+ if (rule.pattern.test(text))
211
+ return { rule, reason: configWriteReason(exec.name, rule) };
212
+ }
213
+ }
214
+ return undefined;
215
+ }
package/lib/detectors.js CHANGED
@@ -36,6 +36,11 @@ export const DENY_SEVERITY = 'high';
36
36
  * delimiters make a match structurally unambiguous, plus PEM blocks and
37
37
  * credential-bearing URLs. Anything requiring entropy heuristics is left to
38
38
  * tier 2, where a false positive costs a redaction rather than a denial.
39
+ *
40
+ * Prefix-anchored is the whole membership criterion, and the reason this table
41
+ * keeps growing rather than deferring to tier 2: the `session-telemetry/record`
42
+ * waterfall is synchronous and cannot reach tier 2 at all, so a format missing
43
+ * here is exported in the clear when telemetry is on.
39
44
  */
40
45
  export const SYNC_RULES = [
41
46
  { id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
@@ -44,9 +49,23 @@ export const SYNC_RULES = [
44
49
  { id: 'dsh-dlp/slack-token', version: 1, severity: 'critical', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
45
50
  { id: 'dsh-dlp/stripe-secret-key', version: 1, severity: 'critical', pattern: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/g },
46
51
  { id: 'dsh-dlp/anthropic-api-key', version: 1, severity: 'critical', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
52
+ // Ahead of the OpenAI rule, whose `sk-` prefix also covers this shape: two
53
+ // detections over the same span merge into one placeholder attributed to
54
+ // whichever rule the table reached first, and the specific rule is the
55
+ // useful attribution.
56
+ { id: 'dsh-dlp/openrouter-api-key', version: 1, severity: 'critical', pattern: /\bsk-or-v1-[0-9a-f]{64}\b/g },
47
57
  { id: 'dsh-dlp/openai-api-key', version: 1, severity: 'critical', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
48
58
  { id: 'dsh-dlp/google-api-key', version: 1, severity: 'critical', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
49
59
  { id: 'dsh-dlp/npm-token', version: 1, severity: 'critical', pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
60
+ { id: 'dsh-dlp/gitlab-token', version: 1, severity: 'critical', pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g },
61
+ { id: 'dsh-dlp/huggingface-token', version: 1, severity: 'critical', pattern: /\bhf_[A-Za-z0-9]{34,}/g },
62
+ { id: 'dsh-dlp/groq-api-key', version: 1, severity: 'critical', pattern: /\bgsk_[A-Za-z0-9]{40,}/g },
63
+ { id: 'dsh-dlp/xai-api-key', version: 1, severity: 'critical', pattern: /\bxai-[A-Za-z0-9]{32,}/g },
64
+ { id: 'dsh-dlp/google-oauth-client-secret', version: 1, severity: 'critical', pattern: /\bGOCSPX-[A-Za-z0-9_-]{24,}/g },
65
+ { id: 'dsh-dlp/databricks-token', version: 1, severity: 'critical', pattern: /\bdapi[0-9a-f]{32}(?:-\d+)?\b/g },
66
+ { id: 'dsh-dlp/sendgrid-api-key', version: 1, severity: 'critical', pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
67
+ { id: 'dsh-dlp/supabase-service-key', version: 1, severity: 'critical', pattern: /\bsbp_[0-9a-f]{40}\b/g },
68
+ { id: 'dsh-dlp/notion-token', version: 1, severity: 'critical', pattern: /\bntn_[A-Za-z0-9]{40,}/g },
50
69
  { id: 'dsh-dlp/private-key-block', version: 1, severity: 'critical', pattern: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g },
51
70
  { id: 'dsh-dlp/json-web-token', version: 1, severity: 'high', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
52
71
  { id: 'dsh-dlp/credential-url', version: 1, severity: 'high', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]+@[^\s/]+/gi },
@@ -58,13 +77,95 @@ export const SYNC_RULES = [
58
77
  { id: 'dsh-dlp/teams-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/[A-Za-z0-9.-]*webhook\.office\.com\/webhookb2\/[A-Za-z0-9@/_-]{10,}/g },
59
78
  { id: 'dsh-dlp/secret-assignment', version: 1, severity: 'medium', pattern: /\b(?:api[_-]?key|secret[_-]?key|client[_-]?secret|password|passwd|access[_-]?token|auth[_-]?token)\b\s*[=:]\s*["']?[A-Za-z0-9/+=_-]{16,}["']?/gi },
60
79
  ];
61
- /** Build one class's run pattern from its ranges, so the two cannot drift apart. */
62
- function unicodeRule(id, action, ranges) {
63
- return { id, version: 1, severity: 'medium', action, ranges, pattern: new RegExp(`[${ranges}]+`, 'gu') };
80
+ /**
81
+ * Build one class's run pattern from its ranges, so the two cannot drift apart.
82
+ * @param id - the rule's identity.
83
+ * @param action - what the scan does with a match.
84
+ * @param ranges - the class's ranges as a character-class body.
85
+ * @param quantifier - applied to the class; the default matches a whole run.
86
+ * @param wholeRun - whether a match must be a complete run rather than part of a longer one.
87
+ * @returns the rule.
88
+ */
89
+ function unicodeRule(id, action, ranges, quantifier = '+', wholeRun = false) {
90
+ const source = wholeRun
91
+ ? `(?<![${ranges}])[${ranges}]${quantifier}(?![${ranges}])`
92
+ : `[${ranges}]${quantifier}`;
93
+ return { id, version: 1, severity: 'medium', action, ranges, pattern: new RegExp(source, 'gu') };
64
94
  }
95
+ /** Variation-selector code points, shared by the isolated rule and the run rule. */
96
+ const VARIATION_SELECTORS = String.raw `\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}`;
97
+ /**
98
+ * Consecutive variation selectors at which the run stops being glyph selection
99
+ * and starts being a payload.
100
+ *
101
+ * One selector picks a glyph: VS15/VS16 after a base character, one selector
102
+ * after one ideograph in an Ideographic Variation Sequence. Two in a row have
103
+ * no standard meaning — an emoji ZWJ sequence separates its selectors with a
104
+ * joiner, so a run stays at one — and four leaves no plausible reading but
105
+ * "these are bytes". GlassWorm encoded executable JavaScript one byte per
106
+ * selector across five waves, 35,800 installs and 300+ repositories, so a real
107
+ * payload is hundreds of selectors long and 4 is a conservative floor rather
108
+ * than a tight one.
109
+ */
110
+ const VARIATION_SELECTOR_RUN = 4;
111
+ /**
112
+ * One terminal control sequence: the full CSI form, not only the SGR colour
113
+ * subset, plus the string-introducer families and the 8-bit C1 equivalents.
114
+ *
115
+ * Each alternative in order: an OSC/DCS/SOS/PM/APC introducer and its body up
116
+ * to a string terminator that may never arrive; a complete CSI — parameter
117
+ * bytes, intermediate bytes, one final byte; any other escape sequence; and a
118
+ * lone escape or C1 control that introduces nothing.
119
+ *
120
+ * Terminating at end of input matters: an unterminated OSC swallows everything
121
+ * a terminal prints after it, which is the whole trick, so the tail is part of
122
+ * the match rather than a miss.
123
+ */
124
+ const CONTROL_SEQUENCE = [
125
+ String.raw `(?:\u001B[\]P^_X]|[\u0090\u0098\u009D\u009E\u009F])[\s\S]*?(?:\u0007|\u001B\\|\u009C|$)`,
126
+ String.raw `(?:\u001B\[|\u009B)[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]`,
127
+ String.raw `\u001B[\u0020-\u002F]*[\u0030-\u007E]`,
128
+ String.raw `[\u001B\u0080-\u009F]`,
129
+ ].join('|');
130
+ /**
131
+ * Text substituted for a stripped control sequence. Visible on purpose: the
132
+ * lanes that strip are the ones an operator reads as evidence, and silently
133
+ * deleting the bytes would hide that a forgery was attempted.
134
+ */
135
+ export const CONTROL_SEQUENCE_PLACEHOLDER = '[REDACTED:dsh-dlp:control-sequence]';
136
+ /**
137
+ * Remove every terminal control sequence from one string.
138
+ *
139
+ * This is the `strip` half of {@link CONTROL_SEQUENCE_RULE}, applied on the
140
+ * lanes that must never carry forgeable bytes: an audit record and the strings
141
+ * an operator or an approval prompt reads back. Ordinary tool-result text takes
142
+ * the `report` half instead, because `git diff`, `rg` and `pytest` legitimately
143
+ * colourise their output.
144
+ * @param text - the string to clean.
145
+ * @returns the string with each control sequence replaced by a visible marker.
146
+ */
147
+ export function stripControlSequences(text) {
148
+ return text.replace(new RegExp(CONTROL_SEQUENCE, 'gu'), CONTROL_SEQUENCE_PLACEHOLDER);
149
+ }
150
+ /**
151
+ * Terminal control sequences in ordinary text.
152
+ *
153
+ * `report` rather than `strip`, deliberately: a tool result carrying SGR colour
154
+ * codes is the normal output of half the commands an agent runs, and replacing
155
+ * them would corrupt every one of those results. What the class buys on that
156
+ * lane is the count in the audit record.
157
+ */
158
+ const CONTROL_SEQUENCE_RULE = {
159
+ id: 'dsh-dlp/control-sequence',
160
+ version: 1,
161
+ severity: 'medium',
162
+ action: 'report',
163
+ pattern: new RegExp(CONTROL_SEQUENCE, 'gu'),
164
+ };
65
165
  /**
66
166
  * Character classes that hide text from the reader while the model still reads
67
- * it, verified against the Unicode character database.
167
+ * it, verified against the Unicode character database, plus the terminal
168
+ * control sequences that show the reader something other than what is there.
68
169
  *
69
170
  * Every class is `medium`. These are injection *indicators*, not credentials:
70
171
  * the guard floor denies at `high` and above, so an argument carrying one is
@@ -88,14 +189,22 @@ export const UNICODE_RULES = [
88
189
  unicodeRule('dsh-dlp/unicode-zero-width', 'report', String.raw `\u{200B}-\u{200D}\u{2060}\u{FEFF}`),
89
190
  // Bidi marks, unlike the overrides above, appear in real right-to-left text.
90
191
  unicodeRule('dsh-dlp/unicode-bidi-mark', 'report', String.raw `\u{061C}\u{200E}\u{200F}`),
91
- unicodeRule('dsh-dlp/unicode-variation-selector', 'report', String.raw `\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}`),
192
+ // An isolated selector is glyph selection and is left alone; a run of them
193
+ // is a byte string wearing the same code points.
194
+ unicodeRule('dsh-dlp/unicode-variation-selector', 'report', VARIATION_SELECTORS, `{1,${VARIATION_SELECTOR_RUN - 1}}`, true),
195
+ unicodeRule('dsh-dlp/unicode-variation-selector-run', 'strip', VARIATION_SELECTORS, `{${VARIATION_SELECTOR_RUN},}`),
196
+ CONTROL_SEQUENCE_RULE,
92
197
  ];
198
+ /** Classes whose matches are a run of one character class. */
199
+ const CHARACTER_RULES = UNICODE_RULES.filter(rule => rule.ranges !== undefined);
200
+ /** Classes whose matches have an ASCII body and are scanned over the whole input. */
201
+ const SEQUENCE_RULES = UNICODE_RULES.filter(rule => rule.ranges === undefined);
93
202
  /**
94
- * One run of any indicator class. The scan is a single pass over the input
95
- * with this pattern; the per-class patterns then run over the matched runs
96
- * only, which are a handful of characters each.
203
+ * One run of any character-class indicator. The scan is a single pass over the
204
+ * input with this pattern; the per-class patterns then run over the matched
205
+ * runs only, which are a handful of characters each.
97
206
  */
98
- const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('')}]+`, 'gu');
207
+ const UNICODE_RUN = new RegExp(`[${[...new Set(CHARACTER_RULES.map(rule => rule.ranges))].join('')}]+`, 'gu');
99
208
  /**
100
209
  * Find every invisible or direction-changing character in one string.
101
210
  *
@@ -107,20 +216,25 @@ const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('
107
216
  */
108
217
  export function scanUnicode(text) {
109
218
  const findings = [];
219
+ const found = (rule, start, length) => {
220
+ findings.push({
221
+ ruleId: rule.id,
222
+ ruleVersion: rule.version,
223
+ severity: rule.severity,
224
+ start,
225
+ end: start + length,
226
+ exact: true,
227
+ action: rule.action,
228
+ });
229
+ };
230
+ for (const rule of SEQUENCE_RULES) {
231
+ for (const match of text.matchAll(rule.pattern))
232
+ found(rule, match.index, match[0].length);
233
+ }
110
234
  for (const run of text.matchAll(UNICODE_RUN)) {
111
- for (const rule of UNICODE_RULES) {
112
- for (const match of run[0].matchAll(rule.pattern)) {
113
- const start = run.index + match.index;
114
- findings.push({
115
- ruleId: rule.id,
116
- ruleVersion: rule.version,
117
- severity: rule.severity,
118
- start,
119
- end: start + match[0].length,
120
- exact: true,
121
- action: rule.action,
122
- });
123
- }
235
+ for (const rule of CHARACTER_RULES) {
236
+ for (const match of run[0].matchAll(rule.pattern))
237
+ found(rule, run.index + match.index, match[0].length);
124
238
  }
125
239
  }
126
240
  findings.sort(byPosition);
package/lib/images.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Neutralising remote markdown images in assistant output, on the `llm/stream`
3
+ * waterfall.
4
+ *
5
+ * The web UI renders any absolute `http:`/`https:` markdown image a model
6
+ * emits as a real `<img src>`, and the harness sets no Content-Security-Policy,
7
+ * so the fetch happens in the user's browser where no host-side listener can
8
+ * see it. This module rewrites the destination out of the assistant's text
9
+ * before it becomes an `assistant/chunk` or `assistant/message` session event,
10
+ * so the log and the rendered answer stay in agreement.
11
+ *
12
+ * Two properties this module exists to hold:
13
+ *
14
+ * - **A destination split across chunks is still caught.** The mock and real
15
+ * adapters both emit text in small deltas, so `![alt](https://host/x)` is
16
+ * routinely spread over several of them and the browser renders the
17
+ * accumulation. Text that could still be the start of an image is held back
18
+ * until it either completes or exceeds {@link MAX_HELD_CHARACTERS}.
19
+ * - **Only the destination is replaced.** The alt text survives, so the
20
+ * sentence the model wrote still reads, and the renderer's own
21
+ * non-absolute-URL arm shows that alt text instead of fetching anything.
22
+ *
23
+ * This does not close the channel. It matches inline image syntax only:
24
+ * reference-style images, an alt text carrying a `]`, and any destination form
25
+ * the pattern does not model still reach the renderer. Raw HTML needs no
26
+ * handling — the renderer keeps it as literal text and no HTML enters the DOM
27
+ * (`packages/client/ui-primitives/src/markdown/render.tsx:261-263`). The
28
+ * upstream fix is one `img-src` directive.
29
+ * @module dsh-dlp/images
30
+ */
31
+ /**
32
+ * Destination substituted for a remote image URL.
33
+ *
34
+ * It is deliberately not a URL: `new URL()` throws on it, which is the
35
+ * renderer's own "not an absolute destination" arm, and that arm renders the
36
+ * alt text as a `<span>` instead of emitting an `<img>`.
37
+ */
38
+ export const BLOCKED_IMAGE_DESTINATION = 'dsh-dlp-blocked-remote-image';
39
+ /**
40
+ * One inline image: alt text, destination, optional title.
41
+ *
42
+ * The destination is either an angle-bracketed form or a run of characters
43
+ * with no whitespace and no parenthesis, which is what CommonMark accepts
44
+ * without balanced-parenthesis nesting.
45
+ */
46
+ const INLINE_IMAGE = /!\[([^\]]*)\]\(\s*(<[^<>\n]*>|[^\s()]*)((?:\s+(?:"[^"]*"|'[^']*'|\([^()]*\)))?)\s*\)/g;
47
+ /**
48
+ * Text that could still become an inline image once more of the stream
49
+ * arrives: an alt text still open, a closed alt text followed by `(`, or a
50
+ * destination not yet closed.
51
+ */
52
+ const PARTIAL_IMAGE = /^!\[[^\]]*(?:\](?:\((?:\s*(?:<[^<>\n]*|[^\s()]*))?)?)?$/;
53
+ /**
54
+ * Longest suffix held back waiting for an image to complete.
55
+ *
56
+ * Held text is text the user cannot see yet, so the wait is bounded: past this
57
+ * many characters the suffix is emitted as it stands and a destination that
58
+ * completes later is caught only by the assembled block. A protocol bound on
59
+ * this module's own buffering, not a deployment choice.
60
+ */
61
+ export const MAX_HELD_CHARACTERS = 4096;
62
+ /**
63
+ * The hostname of an absolute `http(s)` destination.
64
+ * @param destination - the image destination exactly as the model wrote it.
65
+ * @returns the hostname, or `undefined` when the destination is not an absolute HTTP(S) URL.
66
+ */
67
+ function remoteHost(destination) {
68
+ const trimmed = destination.startsWith('<') && destination.endsWith('>')
69
+ ? destination.slice(1, -1)
70
+ : destination;
71
+ let url;
72
+ try {
73
+ url = new URL(trimmed);
74
+ }
75
+ catch {
76
+ // The only failure mode for a string: not an absolute URL, which the
77
+ // renderer also refuses, so there is nothing to neutralise.
78
+ return undefined;
79
+ }
80
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.hostname : undefined;
81
+ }
82
+ /**
83
+ * Replace every absolute HTTP(S) inline image destination in one string.
84
+ * @param text - assistant text, whole or partial.
85
+ * @returns the rewritten text and the hosts whose destinations were replaced.
86
+ */
87
+ export function neutralizeRemoteImages(text) {
88
+ const hosts = [];
89
+ const rewritten = text.replace(INLINE_IMAGE, (match, alt, destination, title) => {
90
+ const host = remoteHost(destination);
91
+ if (host === undefined)
92
+ return match;
93
+ hosts.push(host);
94
+ return `![${alt}](${BLOCKED_IMAGE_DESTINATION}${title})`;
95
+ });
96
+ return { text: rewritten, hosts };
97
+ }
98
+ /**
99
+ * Where the held suffix of a partially streamed string starts.
100
+ * @param text - everything accumulated for one block and not yet emitted.
101
+ * @returns the offset to emit up to; the string's length when nothing is held.
102
+ */
103
+ export function heldSuffixStart(text) {
104
+ const marker = text.lastIndexOf('![');
105
+ if (marker !== -1 && text.length - marker <= MAX_HELD_CHARACTERS && PARTIAL_IMAGE.test(text.slice(marker))) {
106
+ return marker;
107
+ }
108
+ return text.endsWith('!') ? text.length - 1 : text.length;
109
+ }
110
+ /**
111
+ * Wrap one model stream, replacing remote image destinations in its text.
112
+ *
113
+ * Text deltas are rewritten as they pass, with a possible image start held
114
+ * back until it resolves, and the assembled block on `block-end` — which is
115
+ * what the agent loop turns into the assistant message — is rewritten too. A
116
+ * held suffix is always flushed as a delta before the block closes and before
117
+ * the terminal finish, so no text is lost and the emitted chunks still satisfy
118
+ * the stream grammar.
119
+ * @param source - the stream from the rest of the waterfall.
120
+ * @param onNeutralized - notified once per host per text block.
121
+ * @returns the rewritten stream.
122
+ */
123
+ export async function* neutralizeImageStream(source, onNeutralized) {
124
+ const held = new Map();
125
+ const reported = new Map();
126
+ /** Report each host once per block: the deltas and the assembled block carry the same text. */
127
+ const report = (index, hosts) => {
128
+ let seen = reported.get(index);
129
+ if (seen === undefined) {
130
+ seen = new Set();
131
+ reported.set(index, seen);
132
+ }
133
+ for (const host of hosts) {
134
+ if (seen.has(host))
135
+ continue;
136
+ seen.add(host);
137
+ onNeutralized(host);
138
+ }
139
+ };
140
+ /** Emit whatever one block is still holding, so a close or a finish loses nothing. */
141
+ function* flush(index) {
142
+ const pending = held.get(index);
143
+ held.delete(index);
144
+ if (pending !== undefined && pending.length > 0)
145
+ yield { type: 'text-delta', index, text: pending };
146
+ }
147
+ for await (const chunk of source) {
148
+ switch (chunk.type) {
149
+ case 'text-delta': {
150
+ const { text, hosts } = neutralizeRemoteImages((held.get(chunk.index) ?? '') + chunk.text);
151
+ report(chunk.index, hosts);
152
+ const cut = heldSuffixStart(text);
153
+ held.set(chunk.index, text.slice(cut));
154
+ if (cut > 0)
155
+ yield { ...chunk, text: text.slice(0, cut) };
156
+ break;
157
+ }
158
+ case 'block-end': {
159
+ yield* flush(chunk.index);
160
+ if (chunk.block.type !== 'text') {
161
+ yield chunk;
162
+ break;
163
+ }
164
+ const { text, hosts } = neutralizeRemoteImages(chunk.block.text);
165
+ report(chunk.index, hosts);
166
+ yield text === chunk.block.text ? chunk : { ...chunk, block: { ...chunk.block, text } };
167
+ break;
168
+ }
169
+ case 'finish': {
170
+ for (const index of [...held.keys()])
171
+ yield* flush(index);
172
+ yield chunk;
173
+ break;
174
+ }
175
+ default:
176
+ yield chunk;
177
+ }
178
+ }
179
+ // Only a stream that ended without a terminal finish reaches this: the
180
+ // grammar forbids emitting after one, so the flush above already ran.
181
+ for (const index of [...held.keys()])
182
+ yield* flush(index);
183
+ }