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.
- package/README.md +282 -12
- package/SECURITY.md +14 -6
- package/cordis.patch.yml +2 -0
- package/lib/cli.js +8 -4
- package/lib/config-writes.js +215 -0
- package/lib/detectors.js +136 -22
- package/lib/images.js +183 -0
- package/lib/index.js +140 -2
- package/lib/mutation.js +102 -0
- package/lib/paths.js +52 -0
- package/lib/policy.js +25 -10
- package/lib/sink.js +25 -1
- package/lib/telemetry.js +29 -0
- package/lib/types/config-writes.d.ts +89 -0
- package/lib/types/detectors.d.ts +41 -5
- package/lib/types/images.d.ts +81 -0
- package/lib/types/index.d.ts +14 -1
- package/lib/types/mutation.d.ts +83 -0
- package/lib/types/paths.d.ts +25 -0
- package/lib/types/policy.d.ts +11 -1
- package/lib/types/sink.d.ts +18 -1
- package/lib/types/telemetry.d.ts +16 -1
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
95
|
-
* with this pattern; the per-class patterns then run over the matched
|
|
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(`[${
|
|
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
|
|
112
|
-
for (const match of run[0].matchAll(rule.pattern))
|
|
113
|
-
|
|
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 `` 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 ``;
|
|
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
|
+
}
|