dsh-dlp 0.1.0 → 0.3.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/lib/cli.js ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-dlp report` — read this plugin's audit JSONL and say what it decided.
4
+ *
5
+ * The sink is the only evidence a decision happened, and nothing read it: a
6
+ * user could not answer "what did this block today?". This command reads the
7
+ * file directly and imports nothing from the harness, so it runs wherever the
8
+ * package is installed, with no profile and no `dsh` on the path.
9
+ *
10
+ * The file is a durable boundary — written by an older version of this
11
+ * package, appended to under crash — so every line is parsed defensively and a
12
+ * line that is not a record is counted rather than trusted.
13
+ * @module dsh-dlp/cli
14
+ */
15
+ import { readFileSync, realpathSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { defaultAuditLog } from "./home.js";
18
+ /** Decision kinds that stopped a call, as opposed to rewriting its result. */
19
+ const DENYING_KINDS = new Set(['guard-deny', 'pre-execute-deny', 'execution-mutation']);
20
+ /** How many decisions the report lists individually. */
21
+ const RECENT_LIMIT = 10;
22
+ /** Read one string field, or `undefined` when the line does not carry it. */
23
+ function stringField(record, key) {
24
+ const value = record[key];
25
+ return typeof value === 'string' ? value : undefined;
26
+ }
27
+ /** Rule ids named by a record's spans, in file order and without repeats. */
28
+ function ruleIdsOf(record) {
29
+ const spans = record['spans'];
30
+ if (!Array.isArray(spans))
31
+ return [];
32
+ const ids = spans.flatMap((span) => {
33
+ if (typeof span !== 'object' || span === null)
34
+ return [];
35
+ const ruleId = span['ruleId'];
36
+ return typeof ruleId === 'string' ? [ruleId] : [];
37
+ });
38
+ return [...new Set(ids)];
39
+ }
40
+ /** Invisible-character counts a record carries, keeping only numeric entries. */
41
+ function unicodeOf(record) {
42
+ const counts = record['unicode'];
43
+ if (typeof counts !== 'object' || counts === null || Array.isArray(counts))
44
+ return {};
45
+ return Object.fromEntries(Object.entries(counts).flatMap(([key, value]) => typeof value === 'number' ? [[key, value]] : []));
46
+ }
47
+ /**
48
+ * Parse one JSONL line into the fields this command reports on.
49
+ * @param line - one line of the audit file.
50
+ * @returns the record, or `undefined` when the line is not one.
51
+ */
52
+ export function parseRecord(line) {
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(line);
56
+ }
57
+ catch {
58
+ // A torn final line from an interrupted append is the expected cause.
59
+ return undefined;
60
+ }
61
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
62
+ return undefined;
63
+ const record = parsed;
64
+ const kind = stringField(record, 'kind');
65
+ const written = stringField(record, 'time');
66
+ if (kind === undefined || written === undefined)
67
+ return undefined;
68
+ const time = Date.parse(written);
69
+ if (Number.isNaN(time))
70
+ return undefined;
71
+ const tool = stringField(record, 'tool');
72
+ const sessionId = stringField(record, 'sessionId');
73
+ return {
74
+ time,
75
+ kind,
76
+ ...tool === undefined ? {} : { tool },
77
+ ...sessionId === undefined ? {} : { sessionId },
78
+ ruleIds: ruleIdsOf(record),
79
+ unicode: unicodeOf(record),
80
+ };
81
+ }
82
+ /** Text printed for `--help` and alongside a usage error. */
83
+ export const USAGE = [
84
+ 'Usage: dsh-dlp report [options]',
85
+ '',
86
+ 'Reads the JSONL audit sink this plugin writes and summarises what it decided.',
87
+ '',
88
+ 'Options:',
89
+ ' --log <path> audit file to read (default: $DSH_HOME/dsh-dlp.audit.jsonl)',
90
+ ' --since <when> only decisions at or after an ISO timestamp, or a span back',
91
+ ' from now written as 30m, 24h or 7d',
92
+ ' --session <id> only decisions from one session',
93
+ ' --would-have only the decisions that let the call through: the redactions',
94
+ ' and invisible-character findings, which is what a policy that',
95
+ ' denied instead of rewriting would have blocked',
96
+ ' -h, --help print this text',
97
+ ].join('\n');
98
+ /** Milliseconds in one `--since` suffix; {@link parseSince} accepts no other. */
99
+ function spanUnitMs(unit) {
100
+ switch (unit) {
101
+ case 's': return 1000;
102
+ case 'm': return 60_000;
103
+ case 'h': return 3_600_000;
104
+ default: return 86_400_000;
105
+ }
106
+ }
107
+ /**
108
+ * Read `--since`: an ISO timestamp, or a span back from now.
109
+ * @param value - the argument as written.
110
+ * @param now - epoch milliseconds a relative span counts back from.
111
+ * @returns epoch milliseconds, or `undefined` when the value is neither.
112
+ */
113
+ export function parseSince(value, now) {
114
+ if (/^\d+[smhd]$/.test(value))
115
+ return now - Number(value.slice(0, -1)) * spanUnitMs(value.slice(-1));
116
+ const absolute = Date.parse(value);
117
+ return Number.isNaN(absolute) ? undefined : absolute;
118
+ }
119
+ /**
120
+ * Read the command line.
121
+ * @param argv - arguments after the program name.
122
+ * @param env - environment used for the default sink path.
123
+ * @param now - epoch milliseconds a relative `--since` counts back from.
124
+ * @returns what to run, or the usage error to print.
125
+ */
126
+ export function parseArguments(argv, env, now) {
127
+ const [command, ...rest] = argv;
128
+ if (command === undefined || command === '-h' || command === '--help')
129
+ return { kind: 'help' };
130
+ if (command !== 'report')
131
+ return { kind: 'error', message: `dsh-dlp: unknown command ${JSON.stringify(command)}` };
132
+ let log = defaultAuditLog(env);
133
+ let since;
134
+ let session;
135
+ let wouldHave = false;
136
+ let consumed = false;
137
+ for (const [index, flag] of rest.entries()) {
138
+ if (consumed) {
139
+ consumed = false;
140
+ continue;
141
+ }
142
+ switch (flag) {
143
+ case '--log':
144
+ case '--since':
145
+ case '--session': {
146
+ const value = rest[index + 1];
147
+ if (value === undefined)
148
+ return { kind: 'error', message: `dsh-dlp: ${flag} needs a value` };
149
+ consumed = true;
150
+ if (flag === '--log')
151
+ log = value;
152
+ else if (flag === '--session')
153
+ session = value;
154
+ else {
155
+ const parsed = parseSince(value, now);
156
+ if (parsed === undefined) {
157
+ return {
158
+ kind: 'error',
159
+ message: `dsh-dlp: --since ${JSON.stringify(value)} is neither a timestamp nor a span like 24h`,
160
+ };
161
+ }
162
+ since = parsed;
163
+ }
164
+ break;
165
+ }
166
+ case '--would-have':
167
+ wouldHave = true;
168
+ break;
169
+ case '-h':
170
+ case '--help':
171
+ return { kind: 'help' };
172
+ default:
173
+ return { kind: 'error', message: `dsh-dlp: unknown option ${JSON.stringify(flag)}` };
174
+ }
175
+ }
176
+ return {
177
+ kind: 'report',
178
+ options: {
179
+ log,
180
+ ...since === undefined ? {} : { since },
181
+ ...session === undefined ? {} : { session },
182
+ wouldHave,
183
+ },
184
+ };
185
+ }
186
+ /**
187
+ * Read and parse the audit file.
188
+ * @param path - the file to read.
189
+ * @returns its records, its absence, or the problem to print.
190
+ */
191
+ export function readAuditFile(path) {
192
+ let text;
193
+ try {
194
+ text = readFileSync(path, 'utf8');
195
+ }
196
+ catch (error) {
197
+ if (error.code === 'ENOENT')
198
+ return { kind: 'absent' };
199
+ return { kind: 'unreadable', problem: `dsh-dlp: cannot read ${path}: ${String(error)}` };
200
+ }
201
+ const lines = text.split('\n').filter(line => line.trim().length > 0);
202
+ const records = lines.flatMap((line) => {
203
+ const record = parseRecord(line);
204
+ return record === undefined ? [] : [record];
205
+ });
206
+ return { kind: 'read', records, unreadable: lines.length - records.length };
207
+ }
208
+ /** Count each label over the records, most frequent first. */
209
+ function tally(records, label) {
210
+ const counts = new Map();
211
+ for (const record of records) {
212
+ for (const key of label(record))
213
+ counts.set(key, (counts.get(key) ?? 0) + 1);
214
+ }
215
+ return [...counts].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
216
+ }
217
+ /** One `name count` line per entry, padded so the column lines up. */
218
+ function countLines(entries) {
219
+ const width = Math.max(...entries.map(([name]) => name.length));
220
+ return entries.map(([name, count]) => ` ${name.padEnd(width)} ${count}`);
221
+ }
222
+ /** A heading and its counts, or nothing when there are none. */
223
+ function section(heading, entries) {
224
+ return entries.length === 0 ? [] : ['', heading, ...countLines(entries)];
225
+ }
226
+ /**
227
+ * Render the report.
228
+ * @param records - every record the file yielded.
229
+ * @param unreadable - how many of its lines were not records.
230
+ * @param options - the filters the invocation asked for.
231
+ * @returns the lines to print.
232
+ */
233
+ export function formatReport(records, unreadable, options) {
234
+ const selected = records.filter((record) => {
235
+ if (options.since !== undefined && record.time < options.since)
236
+ return false;
237
+ if (options.session !== undefined && record.sessionId !== options.session)
238
+ return false;
239
+ if (options.wouldHave && DENYING_KINDS.has(record.kind))
240
+ return false;
241
+ return true;
242
+ });
243
+ const lines = [`dsh-dlp: ${selected.length} decision(s) in ${options.log}`];
244
+ if (options.since !== undefined)
245
+ lines.push(` since ${new Date(options.since).toISOString()}`);
246
+ if (options.session !== undefined)
247
+ lines.push(` session ${options.session}`);
248
+ if (options.wouldHave)
249
+ lines.push(' only decisions that let the call through');
250
+ if (unreadable > 0)
251
+ lines.push(` ${unreadable} line(s) were not readable as records`);
252
+ if (selected.length === 0)
253
+ return lines;
254
+ lines.push(...section('by decision', tally(selected, record => [record.kind])), ...section('by rule', tally(selected, record => record.ruleIds)), ...section('by tool', tally(selected, record => record.tool === undefined ? [] : [record.tool])), ...section('results carrying invisible characters', tally(selected, record => Object.keys(record.unicode))));
255
+ const recent = [...selected].sort((left, right) => right.time - left.time).slice(0, RECENT_LIMIT);
256
+ lines.push('', `most recent ${recent.length}`);
257
+ for (const record of recent) {
258
+ const rules = record.ruleIds.length === 0 ? '-' : record.ruleIds.join(', ');
259
+ lines.push(` ${new Date(record.time).toISOString()} ${record.kind} ${record.tool ?? '-'} ${rules}`);
260
+ }
261
+ return lines;
262
+ }
263
+ /**
264
+ * Run one invocation.
265
+ * @param argv - arguments after the program name.
266
+ * @param write - receives each line of output.
267
+ * @param fail - receives each line of error output.
268
+ * @param env - environment used for the default sink path.
269
+ * @param now - epoch milliseconds a relative `--since` counts back from.
270
+ * @returns the process exit code.
271
+ */
272
+ export function main(argv, write, fail, env = process.env, now = Date.now()) {
273
+ const invocation = parseArguments(argv, env, now);
274
+ if (invocation.kind === 'help') {
275
+ write(USAGE);
276
+ return 0;
277
+ }
278
+ if (invocation.kind === 'error') {
279
+ fail(invocation.message);
280
+ fail(USAGE);
281
+ return 2;
282
+ }
283
+ const file = readAuditFile(invocation.options.log);
284
+ switch (file.kind) {
285
+ case 'absent':
286
+ write(`dsh-dlp: no audit file at ${invocation.options.log}`);
287
+ write('Nothing has been recorded yet, or the deployment set `auditLog` elsewhere — pass --log <path>.');
288
+ return 0;
289
+ case 'unreadable':
290
+ fail(file.problem);
291
+ return 1;
292
+ case 'read':
293
+ for (const line of formatReport(file.records, file.unreadable, invocation.options))
294
+ write(line);
295
+ return 0;
296
+ /* v8 ignore next 4 -- unreachable while `AuditFileRead` stays closed; the arm exists so adding a variant fails the build. */
297
+ default: {
298
+ const unhandled = file;
299
+ throw new TypeError(`dsh-dlp: unhandled audit file read ${JSON.stringify(unhandled)}`);
300
+ }
301
+ }
302
+ }
303
+ /* v8 ignore start -- the process entry, exercised by tests/e2e/report.e2e.ts against the built CLI rather than by the instrumented unit run. */
304
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
305
+ process.exitCode = main(process.argv.slice(2), line => process.stdout.write(`${line}\n`), line => process.stderr.write(`${line}\n`));
306
+ }
307
+ /* v8 ignore stop */
package/lib/detectors.js CHANGED
@@ -58,11 +58,94 @@ export const SYNC_RULES = [
58
58
  { 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
59
  { 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
60
  ];
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') };
64
+ }
65
+ /**
66
+ * Character classes that hide text from the reader while the model still reads
67
+ * it, verified against the Unicode character database.
68
+ *
69
+ * Every class is `medium`. These are injection *indicators*, not credentials:
70
+ * the guard floor denies at `high` and above, so an argument carrying one is
71
+ * never denied on that basis. What they buy is a redaction and an audit record
72
+ * on a path the harness does not cover — it strips directional controls in
73
+ * exactly one place, session titles, and never on the tool-result path.
74
+ *
75
+ * Not attempted here: UTS #39 confusables. A Cyrillic `а` needs a data table
76
+ * to detect and is a different cost class, and it defeats every rule in this
77
+ * file. README.md says so rather than implying coverage.
78
+ */
79
+ export const UNICODE_RULES = [
80
+ // Tags block: a full ASCII alphabet with no rendering, the standard carrier
81
+ // for instructions meant for the model and not for the reader.
82
+ unicodeRule('dsh-dlp/unicode-tag-characters', 'strip', String.raw `\u{E0000}-\u{E007F}`),
83
+ // Bidi overrides and isolates reorder what is displayed without changing the
84
+ // characters a model reads.
85
+ unicodeRule('dsh-dlp/unicode-bidi-override', 'strip', String.raw `\u{202A}-\u{202E}\u{2066}-\u{2069}`),
86
+ // U+200D joins legitimate emoji sequences, so stripping this class has a
87
+ // real false positive.
88
+ unicodeRule('dsh-dlp/unicode-zero-width', 'report', String.raw `\u{200B}-\u{200D}\u{2060}\u{FEFF}`),
89
+ // Bidi marks, unlike the overrides above, appear in real right-to-left text.
90
+ 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}`),
92
+ ];
93
+ /**
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.
97
+ */
98
+ const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('')}]+`, 'gu');
99
+ /**
100
+ * Find every invisible or direction-changing character in one string.
101
+ *
102
+ * Offsets are UTF-16 indices into `text`, so a caller can splice them
103
+ * directly; they are exact rather than advisory, and {@link Detection.exact}
104
+ * says so.
105
+ * @param text - the string to scan.
106
+ * @returns every indicator run, ordered by start offset.
107
+ */
108
+ export function scanUnicode(text) {
109
+ const findings = [];
110
+ 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
+ }
124
+ }
125
+ }
126
+ findings.sort(byPosition);
127
+ return findings;
128
+ }
129
+ /**
130
+ * How many runs of each indicator class one string carries.
131
+ * @param text - the string to scan.
132
+ * @returns a count per rule id; absent means none were found.
133
+ */
134
+ export function countUnicodeIndicators(text) {
135
+ const counts = {};
136
+ for (const finding of scanUnicode(text)) {
137
+ counts[finding.ruleId] = (counts[finding.ruleId] ?? 0) + 1;
138
+ }
139
+ return counts;
140
+ }
61
141
  /**
62
142
  * Scan text with tier 1. Pure, synchronous, no I/O, and never capped: a table
63
143
  * of anchored regular expressions costs a linear pass, so there is no reason
64
144
  * to stop scanning where tier 2 has to. `truncated` is therefore always
65
145
  * `false` here and only tier 2 can set it.
146
+ *
147
+ * The `strip` half of {@link UNICODE_RULES} is included, so every seam reading
148
+ * tier 1 — including the synchronous telemetry waterfall — gets it.
66
149
  * @param text - the string to scan.
67
150
  * @param rules - the rule table to apply; defaults to {@link SYNC_RULES}.
68
151
  * @returns every match, ordered by start offset.
@@ -82,6 +165,10 @@ export function scanSync(text, rules = SYNC_RULES) {
82
165
  });
83
166
  }
84
167
  }
168
+ for (const finding of scanUnicode(text)) {
169
+ if (finding.action === 'strip')
170
+ detections.push(finding);
171
+ }
85
172
  detections.sort(byPosition);
86
173
  return { detections, truncated: false };
87
174
  }
package/lib/guard.js CHANGED
@@ -27,7 +27,7 @@
27
27
  * @module dsh-dlp/guard
28
28
  */
29
29
  import { DENY_SEVERITY, scanSync, severityRank } from "./detectors.js";
30
- import { isEgressCapable, matchPathArgument, pathArguments, pathCandidates } from "./paths.js";
30
+ import { isEgressCapable, matchPathArgument, pathArguments, pathCandidates, rulesForTool } from "./paths.js";
31
31
  import { nestedStrings } from "./redaction.js";
32
32
  /**
33
33
  * Denial text for a credential-path match.
@@ -57,20 +57,23 @@ function secretArgumentReason(toolName, ruleIds, hashes) {
57
57
  * Credential paths are denied for every tool, not only readers: a shell that
58
58
  * can `cat` a key can also copy it. Only path-typed arguments are tested —
59
59
  * running the table over every string matches file content and denies writing
60
- * a `.gitignore` that mentions `.env`. Argument secrets are denied only for
61
- * egress-capable tools, because denying a local editor for holding the text it
62
- * was asked to write would break ordinary work without closing an exfiltration
63
- * path.
60
+ * a `.gitignore` that mentions `.env`. The one exception is a `writes-only`
61
+ * rule, which a tool classified read-only is exempt from; that is how
62
+ * `$DSH_HOME` stays readable while every write to it is denied. Argument
63
+ * secrets are denied only for egress-capable tools, because denying a local
64
+ * editor for holding the text it was asked to write would break ordinary work
65
+ * without closing an exfiltration path.
64
66
  * @param exec - the pending call as the guard stage sees it.
65
67
  * @param policy - the effective policy after the tighten-only merge.
66
68
  * @param hasher - mints the keyed hashes quoted in a denial reason.
67
69
  * @returns the denial, or `undefined` to abstain.
68
70
  */
69
71
  export function evaluateGuard(exec, policy, hasher) {
72
+ const rules = rulesForTool(exec.name, policy.credentialPathRules);
70
73
  for (const argument of pathArguments(exec.arguments)) {
71
74
  const candidates = argument.shell ? pathCandidates(argument.text) : [argument.text];
72
75
  for (const candidate of candidates) {
73
- const rule = matchPathArgument(candidate, policy.credentialPathRules);
76
+ const rule = matchPathArgument(candidate, rules);
74
77
  if (rule === undefined)
75
78
  continue;
76
79
  const hash = hasher.hash(candidate);
package/lib/home.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where the harness keeps its state, and where this plugin's audit sink lands
3
+ * by default.
4
+ *
5
+ * Its own module so the `dsh-dlp report` command can resolve the sink without
6
+ * importing the plugin: `policy.ts` pulls in `@secretlint/core`, `js-yaml` and
7
+ * the schema library, none of which a reader of a JSONL file needs.
8
+ * @module dsh-dlp/home
9
+ */
10
+ import { homedir } from 'node:os';
11
+ import { join, resolve } from 'node:path';
12
+ /**
13
+ * File name the bundle patch gives the audit sink under the harness home.
14
+ * `cordis.patch.yml` spells the same name; a deployment that sets `auditLog`
15
+ * itself must tell `dsh-dlp report` where it put it.
16
+ */
17
+ export const DEFAULT_AUDIT_LOG_NAME = 'dsh-dlp.audit.jsonl';
18
+ /**
19
+ * Resolve the harness home the same way the harness does: `$DSH_HOME` when it
20
+ * is set to something other than whitespace, otherwise `~/.dsh`. Read here
21
+ * rather than through `@deepseek-ai/dsh-home-paths` to keep the plugin's
22
+ * runtime imports to the ones a profile is guaranteed to resolve.
23
+ * @param env - environment consulted for `DSH_HOME`; defaults to `process.env`.
24
+ * @returns the absolute harness home.
25
+ */
26
+ export function resolveDshHome(env = process.env) {
27
+ const configured = env['DSH_HOME'];
28
+ return resolve(configured !== undefined && configured.trim().length > 0 ? configured : join(homedir(), '.dsh'));
29
+ }
30
+ /**
31
+ * Where the audit sink sits when the deployment did not name one.
32
+ * @param env - environment consulted for `DSH_HOME`; defaults to `process.env`.
33
+ * @returns the absolute path the bundle patch configures.
34
+ */
35
+ export function defaultAuditLog(env = process.env) {
36
+ return join(resolveDshHome(env), DEFAULT_AUDIT_LOG_NAME);
37
+ }
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
+ }