dsh-dlp 0.4.1 → 0.6.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 CHANGED
@@ -20,11 +20,18 @@ built as an out-of-repo plugin.
20
20
  5. **Neutralises remote markdown images in assistant output** and detects a tool call another
21
21
  plugin rewrote after the session log recorded it.
22
22
  6. **Asks before the agent writes a file that changes future behaviour** — agent settings and
23
- hooks, `CLAUDE.md`, `.cursor/rules/**`, `.vscode/tasks.json`, `.mcp.json`, git hooks, CI
24
- workflows, shell startup files and before it writes a `*_BASE_URL` that would redirect a
25
- provider credential.
26
- 7. **Writes an audit record for every decision** — rule id, rule version, offsets, keyed hash.
27
- Never the secret, never the path or command that matched. `dsh-dlp report` reads it back.
23
+ hooks, `CLAUDE.md`, `.claude/rules/**` and the other agent rules directories, prompt
24
+ templates, `.vscode/tasks.json`, `.mcp.json`, git hooks, CI workflows, shell startup files,
25
+ `pnpm-workspace.yaml` — and before it writes a `*_BASE_URL` that would redirect a provider
26
+ credential.
27
+ 7. **Asks before a call switches off its own confirmation** `non_interactive: true`,
28
+ `approval_mode: auto`, an `apply` whose approval is still pending. Both `ask` tiers are
29
+ prompts rather than controls: they live at `tools/pre-execute` and can be neutralised.
30
+ 8. **Writes an audit record for every decision.** A redaction or denial names the rule, its
31
+ version, the offsets and a keyed hash; the three kinds with no matched region to describe —
32
+ an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
33
+ the destination hostname instead. Never the secret, never the path or command that matched.
34
+ `dsh-dlp report` reads it back.
28
35
 
29
36
  ## What this is not
30
37
 
@@ -40,9 +47,12 @@ Three limits worth knowing before you rely on it:
40
47
  - **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
41
48
  registered ahead of ours. `ctx.tools.guard()` is order-independent only because it has no allow
42
49
  arm.
43
- - **The shell-command arm is advisory pattern-matching.** It catches an unobfuscated
44
- `cat ~/.ssh/id_rsa` and nothing that tries one glob character, a `$(printf …)` reassembly or
45
- `python3 -c` all defeat it, each verified. **Do not count this arm as a control.**
50
+ - **The shell-command arm is advisory pattern-matching.** It tests the whole command line and
51
+ each of its tokens, so a credential path left *spelled* in the command is caught whatever
52
+ program would open it: `python3 -c "open('~/.ssh/id_rsa')"` is denied. Changing the spelling
53
+ defeats it — one glob character, quote-splitting, `find -exec`, a substitution that assembles
54
+ the path from pieces, a base64 round-trip, each verified. **Do not count this arm as a
55
+ control.**
46
56
  - **Detection is pattern-based.** No entropy rule (measured, not assumed: at a false-positive-free
47
57
  threshold the miss rate is 100% below 22 characters). Encoded forms pass. A homoglyph defeats
48
58
  every rule in this package.
@@ -83,6 +93,7 @@ load.
83
93
  resultRedaction: true
84
94
  telemetryRedaction: true
85
95
  configWriteAsk: true
96
+ approvalSuppressionAsk: true
86
97
  ```
87
98
 
88
99
  `redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out of
@@ -103,11 +114,14 @@ switch a pass on. Any downgrade makes the whole file invalid.
103
114
  ```sh
104
115
  dsh-dlp report # everything in the audit sink
105
116
  dsh-dlp report --since 24h
106
- dsh-dlp report --kind guard-deny
117
+ dsh-dlp report --session <id>
118
+ dsh-dlp report --would-have # everything except the denials
107
119
  ```
108
120
 
109
- Every record carries a rule id, rule version, span offsets and a keyed hash — never the matched
110
- value.
121
+ A redaction or denial record carries a rule id, rule version, span offsets and a keyed hash —
122
+ never the matched value. An ask carries its rule id, a rewritten call the names of the fields
123
+ that changed, and a neutralised remote image the destination hostname in the clear; none of
124
+ those has a matched region to hash.
111
125
 
112
126
  [Audit record format →](https://charlotten7.github.io/dsh-dlp/audit.html)
113
127
 
package/SECURITY.md CHANGED
@@ -4,12 +4,12 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---|---|
7
- | 0.3.x | yes |
8
- | < 0.3 | no |
7
+ | 0.5.x | yes |
8
+ | < 0.5 | no |
9
9
 
10
- Only the latest published `0.3.x` receives fixes. There is no long-term-support branch while
10
+ Only the latest published `0.5.x` receives fixes. There is no long-term-support branch while
11
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
+ `0.5.x` patch or, if the minor has already moved on, as the next minor.
13
13
 
14
14
  ## Reporting a vulnerability
15
15
 
@@ -32,8 +32,10 @@ This plugin is **not a containment boundary**. It runs in-process at the agent's
32
32
  anything the agent can execute can read the same files the guard denies. The following are
33
33
  documented limits, not vulnerabilities — they are described in README.md:
34
34
 
35
- - shell-command obfuscation defeating the `bash` path arm (globbing, quoting, substitution, a
36
- different binary);
35
+ - shell-command obfuscation defeating the `bash` path arm anything that stops the path being
36
+ spelled in the command line: globbing, quote-splitting, `find -exec`, assembling the path
37
+ from pieces, a base64 round-trip. A command that spells the path is caught whatever program
38
+ it runs, so that is a gap worth reporting;
37
39
  - encoded or split secrets passing both detection tiers;
38
40
  - a secret with no recognisable structure going undetected;
39
41
  - a secret reaching the provider because it was already in the conversation.
package/cordis.patch.yml CHANGED
@@ -22,3 +22,4 @@
22
22
  remoteImageNeutralization: true
23
23
  redactTelemetryWorkspacePaths: true
24
24
  configWriteAsk: true
25
+ approvalSuppressionAsk: true
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The `ask` tier's second rule class: a tool argument whose effect is to switch
3
+ * off the confirmation for the call carrying it.
4
+ *
5
+ * Three 2026 advisories are the same technique. CVE-2026-18733 is
6
+ * `non_interactive: true`; CVE-2026-53808 is `apply: true` while
7
+ * `approvalPolicy` is still `pending`; CVE-2026-56075 is
8
+ * `approval_mode: auto`. In each one the model composes an argument that
9
+ * removes the human from that call, and nothing about the file paths or the
10
+ * secrets involved says so.
11
+ *
12
+ * This is not the mutation check in `mutation.ts` and not the neutralizable
13
+ * seam of the guard floor. Those are about *this plugin's* decision being
14
+ * deleted by something else in the process. This is the model asking a tool to
15
+ * skip the tool's *own* prompt, which every one of those seams would let
16
+ * through as an ordinary, unmodified, secret-free call.
17
+ *
18
+ * It is an `ask` and deliberately not a denial, for the reason the write side
19
+ * is: `non_interactive` also means "no TTY" on plenty of ordinary programs, the
20
+ * tool registry is open, and an unoverridable denial on an argument name is a
21
+ * false denial nobody can work around. ADR.md records the whole weighing.
22
+ * @module dsh-dlp/approvals
23
+ */
24
+ import { isReadOnlyTool } from "./paths.js";
25
+ /**
26
+ * Values that mean the confirmation does not happen.
27
+ *
28
+ * Only the affirmative spellings: `false`, `0` and `no` leave the prompt in
29
+ * place, and a rule that fired on them would prompt on the argument that asks
30
+ * for the prompt.
31
+ */
32
+ const SUPPRESSING_TRUE = /^(?:true|yes|on|1)$/;
33
+ /**
34
+ * Values of an approval-mode argument that name the absence of a prompt.
35
+ *
36
+ * One spelling per mode, because the value is normalized the same way the key
37
+ * is: `full-auto` — which is what Codex writes — `full_auto` and `fullauto`
38
+ * are one entry rather than three.
39
+ */
40
+ const SUPPRESSING_MODE = /^(?:auto|autoapprove|autoedit|never|none|bypass|fullauto|yolo)$/;
41
+ /**
42
+ * Arguments that turn off the human confirmation for the call carrying them.
43
+ *
44
+ * Matched by argument name and value, at any depth of the arguments object,
45
+ * never against the filesystem and never against the tool's name: the registry
46
+ * is open, so a table keyed on tool names would abstain on every MCP tool this
47
+ * build has never heard of — which is where these arguments live.
48
+ */
49
+ export const APPROVAL_SUPPRESSION_RULES = [
50
+ // CVE-2026-18733.
51
+ {
52
+ id: 'dsh-dlp/approval-non-interactive',
53
+ version: 1,
54
+ condition: { key: /^noninteractive$/, value: SUPPRESSING_TRUE },
55
+ effect: 'a non-interactive flag, which runs the call without the confirmation it would otherwise ask for',
56
+ },
57
+ // CVE-2026-56075.
58
+ {
59
+ id: 'dsh-dlp/approval-mode-auto',
60
+ version: 2,
61
+ condition: { key: /^approval(?:mode|policy|setting)$/, value: SUPPRESSING_MODE },
62
+ effect: 'an approval mode that approves on the model\'s behalf instead of asking',
63
+ },
64
+ // CVE-2026-53808: applying while the approval for that change is still
65
+ // pending is what skips the decision, so neither half is a finding alone.
66
+ {
67
+ id: 'dsh-dlp/approval-apply-pending',
68
+ version: 1,
69
+ condition: { key: /^apply$/, value: SUPPRESSING_TRUE },
70
+ alongside: { key: /^approvalpolicy$/, value: /^pending$/ },
71
+ effect: 'an instruction to apply a change whose approval is still pending, which commits it before the answer',
72
+ },
73
+ ];
74
+ /**
75
+ * The spelling one key or value is matched under: lowercase, with the
76
+ * separators that distinguish `non_interactive`, `nonInteractive` and
77
+ * `non-interactive` — and `full-auto` from `full_auto` — removed.
78
+ *
79
+ * Values take the same normalization as keys, and that is the whole reason
80
+ * `approval_mode: full-auto`, which is the spelling Codex writes, reaches the
81
+ * table: enumerating the separator variants one at a time only ever covers the
82
+ * spellings someone already thought of. Every value the rules name is a single
83
+ * word with no legitimate hyphenated or dotted form, so folding the separators
84
+ * away cannot pull an ordinary value in — `on-demand` and `ask-every-time`
85
+ * stay outside the table.
86
+ * @param text - a key as the tool declared it, or a scalar value it carried.
87
+ * @returns the normalized spelling.
88
+ */
89
+ export function normalizeArgumentToken(text) {
90
+ return text.toLowerCase().replace(/[_.-]/g, '');
91
+ }
92
+ /**
93
+ * One argument value as a string, for the values a flag can take.
94
+ * @param node - the value under one argument key.
95
+ * @returns the normalized rendering, or `undefined` for an object or a list.
96
+ */
97
+ function scalarValue(node) {
98
+ if (typeof node === 'boolean' || typeof node === 'number')
99
+ return String(node);
100
+ if (typeof node === 'string')
101
+ return normalizeArgumentToken(node.trim());
102
+ return undefined;
103
+ }
104
+ /** Whether one object carries a key and value the condition describes. */
105
+ function satisfies(entries, condition) {
106
+ for (const [key, value] of entries) {
107
+ if (condition.key.test(key) && condition.value.test(value))
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ /**
113
+ * The first rule any object inside the arguments satisfies.
114
+ *
115
+ * Both halves of a two-part rule must sit on the *same* object: an `apply` in
116
+ * one element of a batch and an `approvalPolicy` in another are two different
117
+ * requests, and pairing them across objects would report a call nobody made.
118
+ * @param args - the pending call's parsed arguments.
119
+ * @param rules - the rule table; defaults to {@link APPROVAL_SUPPRESSION_RULES}.
120
+ * @returns the first matching rule, or `undefined`.
121
+ */
122
+ export function matchApprovalSuppression(args, rules = APPROVAL_SUPPRESSION_RULES) {
123
+ let found;
124
+ const walk = (node) => {
125
+ if (found !== undefined)
126
+ return;
127
+ if (Array.isArray(node)) {
128
+ for (const item of node)
129
+ walk(item);
130
+ return;
131
+ }
132
+ if (typeof node !== 'object' || node === null)
133
+ return;
134
+ const entries = new Map();
135
+ for (const [key, value] of Object.entries(node)) {
136
+ const scalar = scalarValue(value);
137
+ if (scalar !== undefined)
138
+ entries.set(normalizeArgumentToken(key), scalar);
139
+ }
140
+ found = rules.find(rule => satisfies(entries, rule.condition)
141
+ && (rule.alongside === undefined || satisfies(entries, rule.alongside)));
142
+ if (found !== undefined)
143
+ return;
144
+ for (const value of Object.values(node))
145
+ walk(value);
146
+ };
147
+ walk(args);
148
+ return found;
149
+ }
150
+ /**
151
+ * Prompt text for one approval-suppressing argument.
152
+ *
153
+ * The argument's own value is not quoted, for the reason the write side does
154
+ * not quote a path: this string is model-visible, and what the user needs in
155
+ * order to answer is which tool, which rule, and what the argument does.
156
+ */
157
+ function approvalSuppressionReason(toolName, rule) {
158
+ return `dsh-dlp is asking before ${JSON.stringify(toolName)} runs with ${rule.effect} (rule ${rule.id}). `
159
+ + 'The call carries an argument that turns off the confirmation for this call, so the prompt you would '
160
+ + 'normally see is this one. Approve it if you asked for an unattended run; decline it if you did not.';
161
+ }
162
+ /**
163
+ * Decide whether one call suppresses its own confirmation.
164
+ *
165
+ * A tool {@link isReadOnlyTool} classifies as query-only is left alone: it has
166
+ * nothing to confirm, so an argument switching a confirmation off changes
167
+ * nothing there.
168
+ * @param exec - the pending call.
169
+ * @param rules - the rule table; defaults to {@link APPROVAL_SUPPRESSION_RULES}.
170
+ * @returns the finding, or `undefined` to leave the call alone.
171
+ */
172
+ export function evaluateApprovalSuppression(exec, rules = APPROVAL_SUPPRESSION_RULES) {
173
+ if (isReadOnlyTool(exec.name))
174
+ return undefined;
175
+ const rule = matchApprovalSuppression(exec.arguments, rules);
176
+ return rule === undefined ? undefined : { rule, reason: approvalSuppressionReason(exec.name, rule) };
177
+ }
package/lib/cli.js CHANGED
@@ -94,9 +94,9 @@ export const USAGE = [
94
94
  ' --since <when> only decisions at or after an ISO timestamp, or a span back',
95
95
  ' from now written as 30m, 24h or 7d',
96
96
  ' --session <id> only decisions from one session',
97
- ' --would-have only the decisions that let the call through: the redactions',
98
- ' and invisible-character findings, which is what a policy that',
99
- ' denied instead of rewriting would have blocked',
97
+ ' --would-have leave out the decisions that stopped a call, keeping the',
98
+ ' redactions, the invisible-character findings, the asks and',
99
+ ' the neutralised images',
100
100
  ' -h, --help print this text',
101
101
  ].join('\n');
102
102
  /** Milliseconds in one `--since` suffix; {@link parseSince} accepts no other. */
@@ -250,7 +250,7 @@ export function formatReport(records, unreadable, options) {
250
250
  if (options.session !== undefined)
251
251
  lines.push(` session ${options.session}`);
252
252
  if (options.wouldHave)
253
- lines.push(' only decisions that let the call through');
253
+ lines.push(' decisions that stopped a call left out');
254
254
  if (unreadable > 0)
255
255
  lines.push(` ${unreadable} line(s) were not readable as records`);
256
256
  if (selected.length === 0)
@@ -31,6 +31,11 @@ import { nestedStrings } from "./redaction.js";
31
31
  * CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
32
32
  * CVE-2025-59041.
33
33
  *
34
+ * The file is the whole payload, which is why this tier watches the write
35
+ * rather than a later execution: the keyv/cacheable compromise of 2026-08-04
36
+ * placed a `SessionStart` hook in `.claude/settings.json` and was reported to
37
+ * need no `npm install` to take effect.
38
+ *
34
39
  * The rules match by name, never by what is on disk, so a file the call is
35
40
  * about to *create* is matched exactly like one it would change:
36
41
  * CVE-2026-25725 worked precisely because the path did not exist yet and was
@@ -46,25 +51,55 @@ export const CONFIG_WRITE_RULES = [
46
51
  },
47
52
  {
48
53
  id: 'dsh-dlp/config-agent-hooks',
49
- version: 1,
54
+ version: 2,
50
55
  match: 'path',
51
- pattern: /(^|\/)\.(claude|gemini|codex|windsurf|continue)\/hooks(\/|$)/i,
56
+ // `.cursor` is in the sibling settings rule above and was missing here,
57
+ // which left one of the six agent directories' hooks unguarded.
58
+ pattern: /(^|\/)\.(claude|gemini|codex|cursor|windsurf|continue)\/hooks(\/|$)/i,
52
59
  effect: 'an agent hook, which runs on a session event without the model asking for it',
53
60
  },
61
+ // Copilot reads these without any agent asking it to: VS Code documents
62
+ // `.github/copilot-instructions.md` and every `.github/instructions/**.md`
63
+ // as workspace instruction files it applies on its own. They are separate
64
+ // from `config-ci-workflow`, which governs what CI *runs* rather than what a
65
+ // model is told.
54
66
  {
55
- id: 'dsh-dlp/config-agent-instructions',
67
+ id: 'dsh-dlp/config-copilot-instructions',
56
68
  version: 1,
57
69
  match: 'path',
58
- pattern: /(^|\/)(CLAUDE|AGENTS|GEMINI|\.cursorrules|\.windsurfrules)(\.md)?$/i,
70
+ pattern: /(^|\/)\.github\/(copilot-instructions\.md$|instructions\/)/i,
71
+ effect: 'standing instructions the editor feeds to every future session',
72
+ },
73
+ {
74
+ id: 'dsh-dlp/config-agent-instructions',
75
+ version: 2,
76
+ // `CLAUDE.local.md` is the personal, git-ignored companion to `CLAUDE.md`
77
+ // and is read the same way, so covering one and not the other left the
78
+ // quieter of the two files unguarded.
79
+ pattern: /(^|\/)(CLAUDE(\.local)?|AGENTS|GEMINI|\.cursorrules|\.windsurfrules)(\.md)?$/i,
80
+ match: 'path',
59
81
  effect: 'standing instructions every future session in this repository reads',
60
82
  },
83
+ // `.claude/rules` belongs beside the other three: VS Code lists it as a
84
+ // workspace instruction location it detects and applies on its own
85
+ // ("Workspace (Claude format) `.claude/rules` folder"), so it is loaded by an
86
+ // editor a developer never configured for Claude.
61
87
  {
62
88
  id: 'dsh-dlp/config-agent-rules',
63
- version: 1,
89
+ version: 2,
64
90
  match: 'path',
65
- pattern: /(^|\/)\.(cursor|windsurf|continue)\/rules(\/|$)/i,
91
+ pattern: /(^|\/)\.(claude|cursor|windsurf|continue)\/rules(\/|$)/i,
66
92
  effect: 'an always-apply rules file every future session in this repository reads',
67
93
  },
94
+ // CVE-2026-46580. A prompt template is loaded into a later session the same
95
+ // way a rules file is, and the extension is what marks it for loading.
96
+ {
97
+ id: 'dsh-dlp/config-prompt-template',
98
+ version: 1,
99
+ match: 'path',
100
+ pattern: /(^|\/)\.prompts\/.*\.prompttemplate$/i,
101
+ effect: 'a prompt template a later session loads without the model asking for it',
102
+ },
68
103
  {
69
104
  id: 'dsh-dlp/config-mcp-manifest',
70
105
  version: 1,
@@ -114,6 +149,23 @@ export const CONFIG_WRITE_RULES = [
114
149
  pattern: /(^|\/)cordis[^/]*\.ya?ml$/i,
115
150
  effect: 'a harness bundle manifest, which decides which plugins load',
116
151
  },
152
+ // pnpm reads `registry`, `registries` and `namedRegistries` from
153
+ // `pnpm-workspace.yaml`, so the file decides which host the next install
154
+ // downloads packages from. pnpm's own documentation treats it as
155
+ // attacker-controlled for exactly that reason: since v11.5.3 it refuses to
156
+ // expand `${...}` in those settings, "Because `pnpm-workspace.yaml` is
157
+ // committed to the repository, expanding env variables in registry URLs could
158
+ // be exploited by a malicious repository to leak secrets from the environment
159
+ // to an attacker-controlled registry." A literal hostile URL is still obeyed.
160
+ // The `.npmrc` half of the same technique needs no rule here: it is on the
161
+ // guard floor as `dsh-dlp/path-npmrc`, where every call is denied.
162
+ {
163
+ id: 'dsh-dlp/config-pnpm-workspace',
164
+ version: 1,
165
+ match: 'path',
166
+ pattern: /(^|\/)pnpm-workspace\.ya?ml$/i,
167
+ effect: 'the pnpm workspace settings, which decide the registry the next install downloads packages from',
168
+ },
117
169
  // CVE-2026-21852: a repo-local settings file setting `ANTHROPIC_BASE_URL`
118
170
  // sends the user's own API key to whatever host it names. This is neither a
119
171
  // path nor a secret — it is a key whose *value* redirects a credential — so
package/lib/detectors.js CHANGED
@@ -64,7 +64,29 @@ export const SYNC_RULES = [
64
64
  { id: 'dsh-dlp/google-oauth-client-secret', version: 1, severity: 'critical', pattern: /\bGOCSPX-[A-Za-z0-9_-]{24,}/g },
65
65
  { id: 'dsh-dlp/databricks-token', version: 1, severity: 'critical', pattern: /\bdapi[0-9a-f]{32}(?:-\d+)?\b/g },
66
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
+ // Supabase's `sbp_` format is superseded but not retired: the platform's own
68
+ // deprecation notice for the keys it replaces says they "will be deprecated
69
+ // by the end of 2026", so a credential in this format is still live and a
70
+ // rule matching it still fires on something. Removing it would also make
71
+ // every audit record already carrying this rule id uninterpretable.
67
72
  { id: 'dsh-dlp/supabase-service-key', version: 1, severity: 'critical', pattern: /\bsbp_[0-9a-f]{40}\b/g },
73
+ // The current format. Supabase documents the prefixes (`sb_publishable_...`,
74
+ // `sb_secret_...`) but not the suffix, so the rule anchors on the documented
75
+ // prefix and requires enough base64url characters to exclude prose: the keys
76
+ // shown in the announcement discussion carry a 22-character body, a `_`, and
77
+ // a checksum. `sb_publishable_` is deliberately absent — it is the
78
+ // browser-facing replacement for `anon` and is meant to be published, the
79
+ // same reason this table matches Stripe's `sk_live_` and not `pk_live_`.
80
+ { id: 'dsh-dlp/supabase-secret-key', version: 1, severity: 'critical', pattern: /\bsb_secret_[A-Za-z0-9_-]{16,}/g },
81
+ // Cloudflare's scannable format, from the provider's own table: "Each
82
+ // credential type has a distinct prefix followed by 40 characters and a
83
+ // checksum" — `cfk_` for a Global API Key, `cfut_` for a User API Token,
84
+ // `cfat_` for an Account API Token. The checksum's length and character set
85
+ // are not published, so the rule requires the documented 40 and lets the
86
+ // match run to the end of the token. The legacy formats are a bare
87
+ // 40-character alphanumeric string and a 37-45 character hex string, neither
88
+ // of which is prefix-anchored and both of which are therefore tier 2's.
89
+ { id: 'dsh-dlp/cloudflare-api-token', version: 1, severity: 'critical', pattern: /\bcf(?:ut|at|k)_[A-Za-z0-9_-]{40,}/g },
68
90
  { id: 'dsh-dlp/notion-token', version: 1, severity: 'critical', pattern: /\bntn_[A-Za-z0-9]{40,}/g },
69
91
  { id: 'dsh-dlp/private-key-block', version: 1, severity: 'critical', pattern: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g },
70
92
  { 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 },
package/lib/index.js CHANGED
@@ -9,10 +9,11 @@
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
+ * 2b. `tools/pre-execute` — the `ask` tier, for writes to behaviour-changing
13
+ * config paths and for calls carrying an argument that switches their own
14
+ * confirmation off. Deliberately here rather than on the floor: its rules
15
+ * have a real false-positive rate and the floor cannot ask. Neutralizable,
16
+ * and it abstains entirely when no approval service is mounted.
16
17
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
17
18
  * session event is appended, so the durable log records the redacted copy;
18
19
  * a result that cannot be cleaned is withheld rather than accepted.
@@ -41,6 +42,7 @@ import { safeEvaluateGuard } from "./guard.js";
41
42
  import { neutralizeImageStream } from "./images.js";
42
43
  import { ExecutionSnapshots, mutationReason } from "./mutation.js";
43
44
  import { evaluateConfigWrite } from "./config-writes.js";
45
+ import { evaluateApprovalSuppression } from "./approvals.js";
44
46
  import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
45
47
  import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
46
48
  import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
@@ -251,11 +253,12 @@ export function apply(ctx, config) {
251
253
  if (approvalSeamReported)
252
254
  return;
253
255
  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
+ notice(ctx, 'dsh-dlp: the ask tier (configWriteAsk, approvalSuppressionAsk) is enabled, but no approval service'
257
+ + ' is mounted, so an ask would degrade to a denial. This tier abstains instead: a write to a'
258
+ + ' behaviour-changing config path, and a call that switches its own confirmation off, are allowed through'
256
259
  + ' with no prompt. The guard floor is unaffected.');
257
260
  };
258
- if (policy.configWriteAsk) {
261
+ if (policy.configWriteAsk || policy.approvalSuppressionAsk) {
259
262
  // Registered ahead of the breadth tier, so a call that is both a config
260
263
  // write and carries a secret is denied rather than merely asked about:
261
264
  // this listener sees whatever the rest of the waterfall settled on and
@@ -264,7 +267,11 @@ export function apply(ctx, config) {
264
267
  const decision = await next();
265
268
  if (decision.kind !== 'allow')
266
269
  return decision;
267
- const finding = evaluateConfigWrite(exec);
270
+ // The argument that switches a confirmation off is reported ahead of the
271
+ // file it would write: it describes the call itself rather than what the
272
+ // call touches, and it is the one the user has least reason to expect.
273
+ const finding = (policy.approvalSuppressionAsk ? evaluateApprovalSuppression(exec) : undefined)
274
+ ?? (policy.configWriteAsk ? evaluateConfigWrite(exec) : undefined);
268
275
  if (finding === undefined)
269
276
  return decision;
270
277
  // A call the floor will deny anyway is left to the floor. Any non-allow
package/lib/paths.js CHANGED
@@ -40,7 +40,9 @@ export const CREDENTIAL_PATH_RULES = [
40
40
  { id: 'dsh-dlp/path-pypirc', version: 1, pattern: /(^|\/)\.pypirc$/i },
41
41
  { id: 'dsh-dlp/path-git-credentials', version: 1, pattern: /(^|\/)\.git-credentials$/i },
42
42
  { id: 'dsh-dlp/path-gh-config', version: 1, pattern: /(^|\/)\.config\/gh(\/|$)/i },
43
- { id: 'dsh-dlp/path-kubeconfig', version: 2, pattern: /(^|\/)(\.kube\/[^/]*|kubeconfig[^/]*)$/i },
43
+ // `.kube` matches at any depth, like `.aws` and `.azure`: the cached OIDC and
44
+ // exec-plugin tokens sit under `~/.kube/cache/`, not beside the config file.
45
+ { id: 'dsh-dlp/path-kubeconfig', version: 3, pattern: /(^|\/)(\.kube(\/|$)|kubeconfig[^/]*$)/i },
44
46
  { id: 'dsh-dlp/path-kubernetes-conf', version: 1, pattern: /(^|\/)kubernetes\/[^/]*\.conf$/i },
45
47
  { id: 'dsh-dlp/path-docker-config', version: 2, pattern: /(^|\/)(\.docker\/config\.json|\.dockercfg)$/i },
46
48
  { id: 'dsh-dlp/path-gcloud-credentials', version: 1, pattern: /(^|\/)\.config\/gcloud\/[^/]*credential[^/]*$/i },
@@ -53,8 +55,10 @@ export const CREDENTIAL_PATH_RULES = [
53
55
  // token file whatever else the directory holds.
54
56
  { id: 'dsh-dlp/path-agent-auth', version: 1, pattern: /(^|\/)\.?(codex|cursor|composer|windsurf|continue|aider|claude|gemini)\/auth\.json$/i },
55
57
  // 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
+ // are written. The directory alternation is the one above: an agent that
59
+ // keeps an `auth.json` keeps its manifest beside it, and Cursor's own
60
+ // directory is spelled without the dot under `~/.config`.
61
+ { id: 'dsh-dlp/path-agent-mcp-config', version: 2, pattern: /(^|\/)\.?(codex|cursor|composer|windsurf|continue|aider|claude|gemini)\/mcp\.json$/i },
58
62
  // Cursor keeps session tokens in a SQLite state database rather than a
59
63
  // credential file.
60
64
  { id: 'dsh-dlp/path-editor-state-db', version: 1, pattern: /(^|\/)state\.vscdb(-journal|-wal|-shm)?$/i },
package/lib/policy.js CHANGED
@@ -33,6 +33,7 @@ export const Config = z.object({
33
33
  remoteImageNeutralization: z.boolean().default(true),
34
34
  redactTelemetryWorkspacePaths: z.boolean().default(true),
35
35
  configWriteAsk: z.boolean().default(true),
36
+ approvalSuppressionAsk: z.boolean().default(true),
36
37
  });
37
38
  /** Config toggles a repo-local policy may switch on, and never off. */
38
39
  const ENABLEABLE = [
@@ -42,6 +43,7 @@ const ENABLEABLE = [
42
43
  'remoteImageNeutralization',
43
44
  'redactTelemetryWorkspacePaths',
44
45
  'configWriteAsk',
46
+ 'approvalSuppressionAsk',
45
47
  ];
46
48
  /** Keys a repo-local policy file may carry; anything else fails the load. */
47
49
  const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
@@ -284,5 +286,6 @@ export function resolvePolicy(config, repo) {
284
286
  remoteImageNeutralization: enabled('remoteImageNeutralization'),
285
287
  redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
286
288
  configWriteAsk: enabled('configWriteAsk'),
289
+ approvalSuppressionAsk: enabled('approvalSuppressionAsk'),
287
290
  };
288
291
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The `ask` tier's second rule class: a tool argument whose effect is to switch
3
+ * off the confirmation for the call carrying it.
4
+ *
5
+ * Three 2026 advisories are the same technique. CVE-2026-18733 is
6
+ * `non_interactive: true`; CVE-2026-53808 is `apply: true` while
7
+ * `approvalPolicy` is still `pending`; CVE-2026-56075 is
8
+ * `approval_mode: auto`. In each one the model composes an argument that
9
+ * removes the human from that call, and nothing about the file paths or the
10
+ * secrets involved says so.
11
+ *
12
+ * This is not the mutation check in `mutation.ts` and not the neutralizable
13
+ * seam of the guard floor. Those are about *this plugin's* decision being
14
+ * deleted by something else in the process. This is the model asking a tool to
15
+ * skip the tool's *own* prompt, which every one of those seams would let
16
+ * through as an ordinary, unmodified, secret-free call.
17
+ *
18
+ * It is an `ask` and deliberately not a denial, for the reason the write side
19
+ * is: `non_interactive` also means "no TTY" on plenty of ordinary programs, the
20
+ * tool registry is open, and an unoverridable denial on an argument name is a
21
+ * false denial nobody can work around. ADR.md records the whole weighing.
22
+ * @module dsh-dlp/approvals
23
+ */
24
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
25
+ /** One argument key and the value that makes it a finding. */
26
+ export interface ArgumentCondition {
27
+ /** Matched against the key lowercased with `_`, `-` and `.` removed. */
28
+ readonly key: RegExp;
29
+ /** Matched against the value under the same normalization as the key. */
30
+ readonly value: RegExp;
31
+ }
32
+ /** One argument, or pair of arguments, that suppresses a confirmation. */
33
+ export interface ApprovalSuppressionRule {
34
+ readonly id: string;
35
+ readonly version: number;
36
+ readonly condition: ArgumentCondition;
37
+ /**
38
+ * A second pair that must be present on the same object for the rule to
39
+ * fire. `apply: true` on its own is how half the infrastructure tools in
40
+ * existence are driven; it is the pending approval beside it that makes the
41
+ * call skip a decision someone else had not made yet.
42
+ */
43
+ readonly alongside?: ArgumentCondition;
44
+ /** What the argument does, quoted in the prompt the user answers. */
45
+ readonly effect: string;
46
+ }
47
+ /**
48
+ * Arguments that turn off the human confirmation for the call carrying them.
49
+ *
50
+ * Matched by argument name and value, at any depth of the arguments object,
51
+ * never against the filesystem and never against the tool's name: the registry
52
+ * is open, so a table keyed on tool names would abstain on every MCP tool this
53
+ * build has never heard of — which is where these arguments live.
54
+ */
55
+ export declare const APPROVAL_SUPPRESSION_RULES: readonly ApprovalSuppressionRule[];
56
+ /**
57
+ * The spelling one key or value is matched under: lowercase, with the
58
+ * separators that distinguish `non_interactive`, `nonInteractive` and
59
+ * `non-interactive` — and `full-auto` from `full_auto` — removed.
60
+ *
61
+ * Values take the same normalization as keys, and that is the whole reason
62
+ * `approval_mode: full-auto`, which is the spelling Codex writes, reaches the
63
+ * table: enumerating the separator variants one at a time only ever covers the
64
+ * spellings someone already thought of. Every value the rules name is a single
65
+ * word with no legitimate hyphenated or dotted form, so folding the separators
66
+ * away cannot pull an ordinary value in — `on-demand` and `ask-every-time`
67
+ * stay outside the table.
68
+ * @param text - a key as the tool declared it, or a scalar value it carried.
69
+ * @returns the normalized spelling.
70
+ */
71
+ export declare function normalizeArgumentToken(text: string): string;
72
+ /**
73
+ * The first rule any object inside the arguments satisfies.
74
+ *
75
+ * Both halves of a two-part rule must sit on the *same* object: an `apply` in
76
+ * one element of a batch and an `approvalPolicy` in another are two different
77
+ * requests, and pairing them across objects would report a call nobody made.
78
+ * @param args - the pending call's parsed arguments.
79
+ * @param rules - the rule table; defaults to {@link APPROVAL_SUPPRESSION_RULES}.
80
+ * @returns the first matching rule, or `undefined`.
81
+ */
82
+ export declare function matchApprovalSuppression(args: unknown, rules?: readonly ApprovalSuppressionRule[]): ApprovalSuppressionRule | undefined;
83
+ /** A call this tier wants a human to confirm, because the call asked not to be. */
84
+ export interface ApprovalSuppressionFinding {
85
+ readonly rule: ApprovalSuppressionRule;
86
+ /** Model- and user-facing text; names the tool, the rule and what the argument does. */
87
+ readonly reason: string;
88
+ }
89
+ /**
90
+ * Decide whether one call suppresses its own confirmation.
91
+ *
92
+ * A tool {@link isReadOnlyTool} classifies as query-only is left alone: it has
93
+ * nothing to confirm, so an argument switching a confirmation off changes
94
+ * nothing there.
95
+ * @param exec - the pending call.
96
+ * @param rules - the rule table; defaults to {@link APPROVAL_SUPPRESSION_RULES}.
97
+ * @returns the finding, or `undefined` to leave the call alone.
98
+ */
99
+ export declare function evaluateApprovalSuppression(exec: Pick<ToolExecution, 'name' | 'arguments'>, rules?: readonly ApprovalSuppressionRule[]): ApprovalSuppressionFinding | undefined;
100
+ //# sourceMappingURL=approvals.d.ts.map
@@ -36,7 +36,7 @@ export interface ReportOptions {
36
36
  /** Epoch milliseconds; records before it are left out. */
37
37
  readonly since?: number;
38
38
  readonly session?: string;
39
- /** Keep only the decisions that let the call through. */
39
+ /** Leave out the decisions that stopped a call; see {@link DENYING_KINDS}. */
40
40
  readonly wouldHave: boolean;
41
41
  }
42
42
  /** The outcome of reading the command line. */
@@ -45,6 +45,11 @@ export interface ConfigWriteRule {
45
45
  * CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
46
46
  * CVE-2025-59041.
47
47
  *
48
+ * The file is the whole payload, which is why this tier watches the write
49
+ * rather than a later execution: the keyv/cacheable compromise of 2026-08-04
50
+ * placed a `SessionStart` hook in `.claude/settings.json` and was reported to
51
+ * need no `npm install` to take effect.
52
+ *
48
53
  * The rules match by name, never by what is on disk, so a file the call is
49
54
  * about to *create* is matched exactly like one it would change:
50
55
  * CVE-2026-25725 worked precisely because the path did not exist yet and was
@@ -9,10 +9,11 @@
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
+ * 2b. `tools/pre-execute` — the `ask` tier, for writes to behaviour-changing
13
+ * config paths and for calls carrying an argument that switches their own
14
+ * confirmation off. Deliberately here rather than on the floor: its rules
15
+ * have a real false-positive rate and the floor cannot ask. Neutralizable,
16
+ * and it abstains entirely when no approval service is mounted.
16
17
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
17
18
  * session event is appended, so the durable log records the redacted copy;
18
19
  * a result that cannot be cleaned is withheld rather than accepted.
@@ -43,10 +43,16 @@ export interface Config {
43
43
  * letting an `ask` degrade into a denial.
44
44
  */
45
45
  configWriteAsk: boolean;
46
+ /**
47
+ * Whether a call carrying an argument that switches its own confirmation off
48
+ * asks the user first. Shares the `ask` tier, and its approval service, with
49
+ * {@link Config.configWriteAsk}.
50
+ */
51
+ approvalSuppressionAsk: boolean;
46
52
  }
47
53
  export declare const Config: z<Config>;
48
54
  /** Config toggles a repo-local policy may switch on, and never off. */
49
- declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk"];
55
+ declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk", "approvalSuppressionAsk"];
50
56
  /** One toggle name a repo-local policy may name in `enable`. */
51
57
  export type EnableableToggle = typeof ENABLEABLE[number];
52
58
  /** Payload version this package writes and accepts for repo-local policy files. */
@@ -70,6 +76,7 @@ export interface ResolvedPolicy {
70
76
  readonly remoteImageNeutralization: boolean;
71
77
  readonly redactTelemetryWorkspacePaths: boolean;
72
78
  readonly configWriteAsk: boolean;
79
+ readonly approvalSuppressionAsk: boolean;
73
80
  }
74
81
  /** Thrown when a policy file is malformed or attempts to loosen the policy. */
75
82
  export declare class PolicyError extends Error {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Data-loss-prevention plugin for DeepSeek Harness: a non-configurable tool guard floor, tool-result redaction, and fail-closed telemetry redaction",
5
5
  "license": "MIT",
6
6
  "author": "Ivan Tyshchenko <nsof@protonmail.com>",