dsh-dlp 0.3.0 → 0.4.1

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/SECURITY.md CHANGED
@@ -4,11 +4,12 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---|---|
7
- | 0.1.x | yes |
8
- | < 0.1 | no |
7
+ | 0.3.x | yes |
8
+ | < 0.3 | no |
9
9
 
10
- Only the latest published `0.1.x` receives fixes. There is no long-term-support branch while
11
- the package is pre-1.0.
10
+ Only the latest published `0.3.x` receives fixes. There is no long-term-support branch while
11
+ the package is pre-1.0: each minor supersedes the one before it, and a fix ships as the next
12
+ `0.3.x` patch or, if the minor has already moved on, as the next minor.
12
13
 
13
14
  ## Reporting a vulnerability
14
15
 
@@ -44,4 +45,11 @@ These do count, and we want to hear about them:
44
45
  - a secret surviving into the session log through a `tools/post-execute` arm;
45
46
  - a repo-local `policyFile` loosening any part of the floor, executing code, or stalling the
46
47
  agent;
47
- - any way to make the guard abstain that does not require executing code.
48
+ - any way to make the guard abstain that does not require executing code;
49
+ - a terminal control sequence, or any other forgeable bytes, reaching the audit sink or a
50
+ denial the user reads.
51
+
52
+ The `ask` tier for behaviour-changing config paths is **not** part of the floor and is
53
+ documented as neutralizable: it lives at `tools/pre-execute`, so a listener registered ahead of
54
+ it disables it. A missed path there is a gap worth reporting; the fact that another plugin can
55
+ switch the tier off is a stated design limit, not a vulnerability.
package/cordis.patch.yml CHANGED
@@ -21,3 +21,4 @@
21
21
  telemetryRedaction: true
22
22
  remoteImageNeutralization: true
23
23
  redactTelemetryWorkspacePaths: true
24
+ configWriteAsk: true
package/lib/cli.js CHANGED
@@ -24,18 +24,22 @@ function stringField(record, key) {
24
24
  const value = record[key];
25
25
  return typeof value === 'string' ? value : undefined;
26
26
  }
27
- /** Rule ids named by a record's spans, in file order and without repeats. */
27
+ /**
28
+ * Rule ids a record names, in file order and without repeats: one per span,
29
+ * plus the top-level `ruleId` a decision with no matched region carries.
30
+ */
28
31
  function ruleIdsOf(record) {
32
+ const named = stringField(record, 'ruleId');
29
33
  const spans = record['spans'];
30
34
  if (!Array.isArray(spans))
31
- return [];
35
+ return named === undefined ? [] : [named];
32
36
  const ids = spans.flatMap((span) => {
33
37
  if (typeof span !== 'object' || span === null)
34
38
  return [];
35
39
  const ruleId = span['ruleId'];
36
40
  return typeof ruleId === 'string' ? [ruleId] : [];
37
41
  });
38
- return [...new Set(ids)];
42
+ return [...new Set(named === undefined ? ids : [named, ...ids])];
39
43
  }
40
44
  /** Invisible-character counts a record carries, keeping only numeric entries. */
41
45
  function unicodeOf(record) {
@@ -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,94 @@ 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, so a real payload is hundreds of selectors long
107
+ * and 4 is a conservative floor rather than a tight one.
108
+ */
109
+ const VARIATION_SELECTOR_RUN = 4;
110
+ /**
111
+ * One terminal control sequence: the full CSI form, not only the SGR colour
112
+ * subset, plus the string-introducer families and the 8-bit C1 equivalents.
113
+ *
114
+ * Each alternative in order: an OSC/DCS/SOS/PM/APC introducer and its body up
115
+ * to a string terminator that may never arrive; a complete CSI — parameter
116
+ * bytes, intermediate bytes, one final byte; any other escape sequence; and a
117
+ * lone escape or C1 control that introduces nothing.
118
+ *
119
+ * Terminating at end of input matters: an unterminated OSC swallows everything
120
+ * a terminal prints after it, which is the whole trick, so the tail is part of
121
+ * the match rather than a miss.
122
+ */
123
+ const CONTROL_SEQUENCE = [
124
+ String.raw `(?:\u001B[\]P^_X]|[\u0090\u0098\u009D\u009E\u009F])[\s\S]*?(?:\u0007|\u001B\\|\u009C|$)`,
125
+ String.raw `(?:\u001B\[|\u009B)[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]`,
126
+ String.raw `\u001B[\u0020-\u002F]*[\u0030-\u007E]`,
127
+ String.raw `[\u001B\u0080-\u009F]`,
128
+ ].join('|');
129
+ /**
130
+ * Text substituted for a stripped control sequence. Visible on purpose: the
131
+ * lanes that strip are the ones an operator reads as evidence, and silently
132
+ * deleting the bytes would hide that a forgery was attempted.
133
+ */
134
+ export const CONTROL_SEQUENCE_PLACEHOLDER = '[REDACTED:dsh-dlp:control-sequence]';
135
+ /**
136
+ * Remove every terminal control sequence from one string.
137
+ *
138
+ * This is the `strip` half of {@link CONTROL_SEQUENCE_RULE}, applied on the
139
+ * lanes that must never carry forgeable bytes: an audit record and the strings
140
+ * an operator or an approval prompt reads back. Ordinary tool-result text takes
141
+ * the `report` half instead, because `git diff`, `rg` and `pytest` legitimately
142
+ * colourise their output.
143
+ * @param text - the string to clean.
144
+ * @returns the string with each control sequence replaced by a visible marker.
145
+ */
146
+ export function stripControlSequences(text) {
147
+ return text.replace(new RegExp(CONTROL_SEQUENCE, 'gu'), CONTROL_SEQUENCE_PLACEHOLDER);
148
+ }
149
+ /**
150
+ * Terminal control sequences in ordinary text.
151
+ *
152
+ * `report` rather than `strip`, deliberately: a tool result carrying SGR colour
153
+ * codes is the normal output of half the commands an agent runs, and replacing
154
+ * them would corrupt every one of those results. What the class buys on that
155
+ * lane is the count in the audit record.
156
+ */
157
+ const CONTROL_SEQUENCE_RULE = {
158
+ id: 'dsh-dlp/control-sequence',
159
+ version: 1,
160
+ severity: 'medium',
161
+ action: 'report',
162
+ pattern: new RegExp(CONTROL_SEQUENCE, 'gu'),
163
+ };
65
164
  /**
66
165
  * Character classes that hide text from the reader while the model still reads
67
- * it, verified against the Unicode character database.
166
+ * it, verified against the Unicode character database, plus the terminal
167
+ * control sequences that show the reader something other than what is there.
68
168
  *
69
169
  * Every class is `medium`. These are injection *indicators*, not credentials:
70
170
  * the guard floor denies at `high` and above, so an argument carrying one is
@@ -88,14 +188,22 @@ export const UNICODE_RULES = [
88
188
  unicodeRule('dsh-dlp/unicode-zero-width', 'report', String.raw `\u{200B}-\u{200D}\u{2060}\u{FEFF}`),
89
189
  // Bidi marks, unlike the overrides above, appear in real right-to-left text.
90
190
  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}`),
191
+ // An isolated selector is glyph selection and is left alone; a run of them
192
+ // is a byte string wearing the same code points.
193
+ unicodeRule('dsh-dlp/unicode-variation-selector', 'report', VARIATION_SELECTORS, `{1,${VARIATION_SELECTOR_RUN - 1}}`, true),
194
+ unicodeRule('dsh-dlp/unicode-variation-selector-run', 'strip', VARIATION_SELECTORS, `{${VARIATION_SELECTOR_RUN},}`),
195
+ CONTROL_SEQUENCE_RULE,
92
196
  ];
197
+ /** Classes whose matches are a run of one character class. */
198
+ const CHARACTER_RULES = UNICODE_RULES.filter(rule => rule.ranges !== undefined);
199
+ /** Classes whose matches have an ASCII body and are scanned over the whole input. */
200
+ const SEQUENCE_RULES = UNICODE_RULES.filter(rule => rule.ranges === undefined);
93
201
  /**
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.
202
+ * One run of any character-class indicator. The scan is a single pass over the
203
+ * input with this pattern; the per-class patterns then run over the matched
204
+ * runs only, which are a handful of characters each.
97
205
  */
98
- const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('')}]+`, 'gu');
206
+ const UNICODE_RUN = new RegExp(`[${[...new Set(CHARACTER_RULES.map(rule => rule.ranges))].join('')}]+`, 'gu');
99
207
  /**
100
208
  * Find every invisible or direction-changing character in one string.
101
209
  *
@@ -107,20 +215,25 @@ const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('
107
215
  */
108
216
  export function scanUnicode(text) {
109
217
  const findings = [];
218
+ const found = (rule, start, length) => {
219
+ findings.push({
220
+ ruleId: rule.id,
221
+ ruleVersion: rule.version,
222
+ severity: rule.severity,
223
+ start,
224
+ end: start + length,
225
+ exact: true,
226
+ action: rule.action,
227
+ });
228
+ };
229
+ for (const rule of SEQUENCE_RULES) {
230
+ for (const match of text.matchAll(rule.pattern))
231
+ found(rule, match.index, match[0].length);
232
+ }
110
233
  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
- }
234
+ for (const rule of CHARACTER_RULES) {
235
+ for (const match of run[0].matchAll(rule.pattern))
236
+ found(rule, run.index + match.index, match[0].length);
124
237
  }
125
238
  }
126
239
  findings.sort(byPosition);
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dsh-dlp` — data-loss prevention for DeepSeek Harness.
3
3
  *
4
- * Four registrations, in descending order of how much they can be trusted:
4
+ * Six registrations, in descending order of how much they can be trusted:
5
5
  *
6
6
  * 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
7
7
  * credential paths named in a path-typed argument and for secrets heading
@@ -9,6 +9,10 @@
9
9
  * has no allow arm.
10
10
  * 2. `tools/pre-execute` — the async breadth tier, which can await
11
11
  * `@secretlint/core`. Neutralizable by any listener registered ahead of it.
12
+ * 2b. `tools/pre-execute` — the `ask` tier for writes to behaviour-changing
13
+ * config paths. Deliberately here rather than on the floor: its rules have a
14
+ * real false-positive rate and the floor cannot ask. Neutralizable, and it
15
+ * abstains entirely when no approval service is mounted.
12
16
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
13
17
  * session event is appended, so the durable log records the redacted copy;
14
18
  * a result that cannot be cleaned is withheld rather than accepted.
@@ -36,6 +40,7 @@ import { SpanHasher } from "./redaction.js";
36
40
  import { safeEvaluateGuard } from "./guard.js";
37
41
  import { neutralizeImageStream } from "./images.js";
38
42
  import { ExecutionSnapshots, mutationReason } from "./mutation.js";
43
+ import { evaluateConfigWrite } from "./config-writes.js";
39
44
  import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
40
45
  import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
41
46
  import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
@@ -230,6 +235,59 @@ export function apply(ctx, config) {
230
235
  });
231
236
  return verdict.reason;
232
237
  }), 'dsh-dlp guard floor');
238
+ /**
239
+ * Report once that the `ask` tier has nowhere to ask.
240
+ *
241
+ * The registry resolves an `ask` through `ctx.get('approval')` and keeps the
242
+ * historical degrade to *deny* when no service is composed. This tier exists
243
+ * because its rules are too false-positive-prone for a deny, so under a
244
+ * deployment with no approval channel it abstains instead of becoming the
245
+ * silent hard deny it was designed not to be. Evaluated at decision time
246
+ * rather than at mount, because by then the harness is running and an absent
247
+ * service is conclusive rather than a load order.
248
+ */
249
+ let approvalSeamReported = false;
250
+ const discloseApprovalSeam = () => {
251
+ if (approvalSeamReported)
252
+ return;
253
+ approvalSeamReported = true;
254
+ notice(ctx, 'dsh-dlp: configWriteAsk is enabled, but no approval service is mounted, so an ask would degrade'
255
+ + ' to a denial. This tier abstains instead: a write to a behaviour-changing config path is allowed through'
256
+ + ' with no prompt. The guard floor is unaffected.');
257
+ };
258
+ if (policy.configWriteAsk) {
259
+ // Registered ahead of the breadth tier, so a call that is both a config
260
+ // write and carries a secret is denied rather than merely asked about:
261
+ // this listener sees whatever the rest of the waterfall settled on and
262
+ // only ever narrows `allow` into `ask`.
263
+ ctx.on('tools/pre-execute', async (exec, next) => {
264
+ const decision = await next();
265
+ if (decision.kind !== 'allow')
266
+ return decision;
267
+ const finding = evaluateConfigWrite(exec);
268
+ if (finding === undefined)
269
+ return decision;
270
+ // A call the floor will deny anyway is left to the floor. Any non-allow
271
+ // decision from this waterfall skips guards entirely, so asking here
272
+ // would replace an unconditional denial with a prompt a user can grant,
273
+ // and would file the decision as an ask rather than as a guard denial.
274
+ if (safeEvaluateGuard(exec, policy, hasher) !== undefined)
275
+ return decision;
276
+ if (ctx.get('approval') === undefined) {
277
+ discloseApprovalSeam();
278
+ return decision;
279
+ }
280
+ sink.write({
281
+ v: RECORD_VERSION,
282
+ time: new Date().toISOString(),
283
+ kind: 'pre-execute-ask',
284
+ decisionId: newDecisionId(),
285
+ ...identity(exec),
286
+ ruleId: finding.rule.id,
287
+ });
288
+ return { kind: 'ask', reason: finding.reason };
289
+ });
290
+ }
233
291
  if (policy.breadthTier) {
234
292
  ctx.on('tools/pre-execute', async (exec, next) => {
235
293
  const decision = await next();
package/lib/paths.js CHANGED
@@ -48,9 +48,61 @@ export const CREDENTIAL_PATH_RULES = [
48
48
  { id: 'dsh-dlp/path-pgpass', version: 1, pattern: /(^|\/)\.pgpass$/i },
49
49
  { id: 'dsh-dlp/path-mysql-config', version: 1, pattern: /(^|\/)\.my\.cnf$/i },
50
50
  { id: 'dsh-dlp/path-service-account', version: 1, pattern: /(^|\/)[^/]*service[._-]?account[^/]*\.json$/i },
51
+ // Coding-agent credential stores. IronWorm's 44 packages and SANDWORM_MODE
52
+ // name these verbatim; an `auth.json` under an agent's own directory is a
53
+ // token file whatever else the directory holds.
54
+ { id: 'dsh-dlp/path-agent-auth', version: 1, pattern: /(^|\/)\.?(codex|cursor|composer|windsurf|continue|aider|claude|gemini)\/auth\.json$/i },
55
+ // An MCP manifest carries each server's `env`, which is where its API keys
56
+ // are written.
57
+ { id: 'dsh-dlp/path-agent-mcp-config', version: 1, pattern: /(^|\/)\.(cursor|windsurf|continue|codex|claude|gemini)\/mcp\.json$/i },
58
+ // Cursor keeps session tokens in a SQLite state database rather than a
59
+ // credential file.
60
+ { id: 'dsh-dlp/path-editor-state-db', version: 1, pattern: /(^|\/)state\.vscdb(-journal|-wal|-shm)?$/i },
61
+ { id: 'dsh-dlp/path-macos-keychain', version: 1, pattern: /(^|\/)Library\/Keychains(\/|$)/i },
62
+ // A Terraform variables file is where provider credentials are written by
63
+ // convention, and state holds every provider's secrets in plaintext.
64
+ { id: 'dsh-dlp/path-terraform-vars', version: 1, pattern: /\.tfvars(\.json)?$/i },
65
+ { id: 'dsh-dlp/path-terraform-state', version: 1, pattern: /(^|\/)terraform\.tfstate(\.backup)?$/i },
51
66
  { id: 'dsh-dlp/path-keystore', version: 2, pattern: /\.(pem|p12|pfx|jks|keystore|key|asc|gpg)$/i },
52
67
  { id: 'dsh-dlp/path-credential-name', version: 1, pattern: new RegExp(String.raw `(^|\/)(?!.*\.(?:${CODE_EXTENSIONS})$)[^/]*(credentials?|secrets?|tokens?)([._-][^/]*)?$`, 'i') },
53
68
  ];
69
+ /**
70
+ * Escape one literal path so it can anchor a regular expression.
71
+ * @param literal - the path to quote.
72
+ * @returns the same text with every metacharacter escaped.
73
+ */
74
+ export function escapePathPattern(literal) {
75
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
76
+ }
77
+ /**
78
+ * Credential-path rules anchored at the user's home directory, resolved at
79
+ * mount because the directory is not known until then.
80
+ *
81
+ * A coding agent's *home* configuration decides how every future session in
82
+ * every repository behaves: the Miasma worm's `SessionStart` hooks went into
83
+ * exactly these files. Writing one is never ordinary repository work, so it is
84
+ * on the floor. Reading one is — a user asking the agent why its own
85
+ * configuration behaves a certain way is a normal request — so the rule is
86
+ * `writes-only` rather than `every-call`, unlike the `auth.json` and `mcp.json`
87
+ * stores in {@link CREDENTIAL_PATH_RULES}, which hold nothing but credentials.
88
+ *
89
+ * The *repository-local* copies of these same file names are a different
90
+ * question with a different answer: they are edited legitimately and often, so
91
+ * they sit in the neutralizable `ask` tier rather than on the floor.
92
+ * @param home - the user's home directory.
93
+ * @returns rules appended after the built-in table.
94
+ */
95
+ export function homeCredentialPathRules(home) {
96
+ // `~` survives normalization as a root-anchored marker, so a home-relative
97
+ // spelling reaches the same rule as the absolute one.
98
+ const anchor = `(?:${escapePathPattern(home)}|/~)`;
99
+ return [{
100
+ id: 'dsh-dlp/path-agent-home-settings',
101
+ version: 1,
102
+ enforcement: 'writes-only',
103
+ pattern: new RegExp(`^${anchor}/\\.(claude|gemini|codex|cursor|windsurf|continue)/settings[^/]*\\.json$`, 'i'),
104
+ }];
105
+ }
54
106
  /**
55
107
  * Normalize one candidate path for matching: Windows separators become
56
108
  * forward slashes, surrounding quotes come off, `~` expands to a