dsh-dlp 0.4.1 → 0.5.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 +9 -4
- package/cordis.patch.yml +1 -0
- package/lib/approvals.js +163 -0
- package/lib/config-writes.js +54 -4
- package/lib/detectors.js +22 -0
- package/lib/index.js +15 -8
- package/lib/policy.js +3 -0
- package/lib/types/approvals.d.ts +92 -0
- package/lib/types/config-writes.d.ts +5 -0
- package/lib/types/index.d.ts +5 -4
- package/lib/types/policy.d.ts +8 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,10 +20,14 @@ 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`, `.
|
|
24
|
-
|
|
25
|
-
provider
|
|
26
|
-
|
|
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** — rule id, rule version, offsets, keyed hash.
|
|
27
31
|
Never the secret, never the path or command that matched. `dsh-dlp report` reads it back.
|
|
28
32
|
|
|
29
33
|
## What this is not
|
|
@@ -83,6 +87,7 @@ load.
|
|
|
83
87
|
resultRedaction: true
|
|
84
88
|
telemetryRedaction: true
|
|
85
89
|
configWriteAsk: true
|
|
90
|
+
approvalSuppressionAsk: true
|
|
86
91
|
```
|
|
87
92
|
|
|
88
93
|
`redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out of
|
package/cordis.patch.yml
CHANGED
package/lib/approvals.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
/** Values of an approval-mode argument that name the absence of a prompt. */
|
|
34
|
+
const SUPPRESSING_MODE = /^(?:auto|autoapprove|autoedit|never|none|bypass|fullauto|yolo)$/;
|
|
35
|
+
/**
|
|
36
|
+
* Arguments that turn off the human confirmation for the call carrying them.
|
|
37
|
+
*
|
|
38
|
+
* Matched by argument name and value, at any depth of the arguments object,
|
|
39
|
+
* never against the filesystem and never against the tool's name: the registry
|
|
40
|
+
* is open, so a table keyed on tool names would abstain on every MCP tool this
|
|
41
|
+
* build has never heard of — which is where these arguments live.
|
|
42
|
+
*/
|
|
43
|
+
export const APPROVAL_SUPPRESSION_RULES = [
|
|
44
|
+
// CVE-2026-18733.
|
|
45
|
+
{
|
|
46
|
+
id: 'dsh-dlp/approval-non-interactive',
|
|
47
|
+
version: 1,
|
|
48
|
+
condition: { key: /^noninteractive$/, value: SUPPRESSING_TRUE },
|
|
49
|
+
effect: 'a non-interactive flag, which runs the call without the confirmation it would otherwise ask for',
|
|
50
|
+
},
|
|
51
|
+
// CVE-2026-56075.
|
|
52
|
+
{
|
|
53
|
+
id: 'dsh-dlp/approval-mode-auto',
|
|
54
|
+
version: 1,
|
|
55
|
+
condition: { key: /^approval(?:mode|policy|setting)$/, value: SUPPRESSING_MODE },
|
|
56
|
+
effect: 'an approval mode that approves on the model\'s behalf instead of asking',
|
|
57
|
+
},
|
|
58
|
+
// CVE-2026-53808: applying while the approval for that change is still
|
|
59
|
+
// pending is what skips the decision, so neither half is a finding alone.
|
|
60
|
+
{
|
|
61
|
+
id: 'dsh-dlp/approval-apply-pending',
|
|
62
|
+
version: 1,
|
|
63
|
+
condition: { key: /^apply$/, value: SUPPRESSING_TRUE },
|
|
64
|
+
alongside: { key: /^approvalpolicy$/, value: /^pending$/ },
|
|
65
|
+
effect: 'an instruction to apply a change whose approval is still pending, which commits it before the answer',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* The spelling one argument key is matched under: lowercase, with the
|
|
70
|
+
* separators that distinguish `non_interactive`, `nonInteractive` and
|
|
71
|
+
* `non-interactive` removed.
|
|
72
|
+
* @param key - the key as the tool declared it.
|
|
73
|
+
* @returns the normalized spelling.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeArgumentKey(key) {
|
|
76
|
+
return key.toLowerCase().replace(/[_.-]/g, '');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* One argument value as a string, for the values a flag can take.
|
|
80
|
+
* @param node - the value under one argument key.
|
|
81
|
+
* @returns the lowercased rendering, or `undefined` for an object or a list.
|
|
82
|
+
*/
|
|
83
|
+
function scalarValue(node) {
|
|
84
|
+
if (typeof node === 'boolean' || typeof node === 'number')
|
|
85
|
+
return String(node);
|
|
86
|
+
if (typeof node === 'string')
|
|
87
|
+
return node.trim().toLowerCase();
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Whether one object carries a key and value the condition describes. */
|
|
91
|
+
function satisfies(entries, condition) {
|
|
92
|
+
for (const [key, value] of entries) {
|
|
93
|
+
if (condition.key.test(key) && condition.value.test(value))
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The first rule any object inside the arguments satisfies.
|
|
100
|
+
*
|
|
101
|
+
* Both halves of a two-part rule must sit on the *same* object: an `apply` in
|
|
102
|
+
* one element of a batch and an `approvalPolicy` in another are two different
|
|
103
|
+
* requests, and pairing them across objects would report a call nobody made.
|
|
104
|
+
* @param args - the pending call's parsed arguments.
|
|
105
|
+
* @param rules - the rule table; defaults to {@link APPROVAL_SUPPRESSION_RULES}.
|
|
106
|
+
* @returns the first matching rule, or `undefined`.
|
|
107
|
+
*/
|
|
108
|
+
export function matchApprovalSuppression(args, rules = APPROVAL_SUPPRESSION_RULES) {
|
|
109
|
+
let found;
|
|
110
|
+
const walk = (node) => {
|
|
111
|
+
if (found !== undefined)
|
|
112
|
+
return;
|
|
113
|
+
if (Array.isArray(node)) {
|
|
114
|
+
for (const item of node)
|
|
115
|
+
walk(item);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (typeof node !== 'object' || node === null)
|
|
119
|
+
return;
|
|
120
|
+
const entries = new Map();
|
|
121
|
+
for (const [key, value] of Object.entries(node)) {
|
|
122
|
+
const scalar = scalarValue(value);
|
|
123
|
+
if (scalar !== undefined)
|
|
124
|
+
entries.set(normalizeArgumentKey(key), scalar);
|
|
125
|
+
}
|
|
126
|
+
found = rules.find(rule => satisfies(entries, rule.condition)
|
|
127
|
+
&& (rule.alongside === undefined || satisfies(entries, rule.alongside)));
|
|
128
|
+
if (found !== undefined)
|
|
129
|
+
return;
|
|
130
|
+
for (const value of Object.values(node))
|
|
131
|
+
walk(value);
|
|
132
|
+
};
|
|
133
|
+
walk(args);
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Prompt text for one approval-suppressing argument.
|
|
138
|
+
*
|
|
139
|
+
* The argument's own value is not quoted, for the reason the write side does
|
|
140
|
+
* not quote a path: this string is model-visible, and what the user needs in
|
|
141
|
+
* order to answer is which tool, which rule, and what the argument does.
|
|
142
|
+
*/
|
|
143
|
+
function approvalSuppressionReason(toolName, rule) {
|
|
144
|
+
return `dsh-dlp is asking before ${JSON.stringify(toolName)} runs with ${rule.effect} (rule ${rule.id}). `
|
|
145
|
+
+ 'The call carries an argument that turns off the confirmation for this call, so the prompt you would '
|
|
146
|
+
+ 'normally see is this one. Approve it if you asked for an unattended run; decline it if you did not.';
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Decide whether one call suppresses its own confirmation.
|
|
150
|
+
*
|
|
151
|
+
* A tool {@link isReadOnlyTool} classifies as query-only is left alone: it has
|
|
152
|
+
* nothing to confirm, so an argument switching a confirmation off changes
|
|
153
|
+
* nothing there.
|
|
154
|
+
* @param exec - the pending call.
|
|
155
|
+
* @param rules - the rule table; defaults to {@link APPROVAL_SUPPRESSION_RULES}.
|
|
156
|
+
* @returns the finding, or `undefined` to leave the call alone.
|
|
157
|
+
*/
|
|
158
|
+
export function evaluateApprovalSuppression(exec, rules = APPROVAL_SUPPRESSION_RULES) {
|
|
159
|
+
if (isReadOnlyTool(exec.name))
|
|
160
|
+
return undefined;
|
|
161
|
+
const rule = matchApprovalSuppression(exec.arguments, rules);
|
|
162
|
+
return rule === undefined ? undefined : { rule, reason: approvalSuppressionReason(exec.name, rule) };
|
|
163
|
+
}
|
package/lib/config-writes.js
CHANGED
|
@@ -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
|
|
@@ -51,20 +56,48 @@ export const CONFIG_WRITE_RULES = [
|
|
|
51
56
|
pattern: /(^|\/)\.(claude|gemini|codex|windsurf|continue)\/hooks(\/|$)/i,
|
|
52
57
|
effect: 'an agent hook, which runs on a session event without the model asking for it',
|
|
53
58
|
},
|
|
59
|
+
// Copilot reads these without any agent asking it to: VS Code documents
|
|
60
|
+
// `.github/copilot-instructions.md` and every `.github/instructions/**.md`
|
|
61
|
+
// as workspace instruction files it applies on its own. They are separate
|
|
62
|
+
// from `config-ci-workflow`, which governs what CI *runs* rather than what a
|
|
63
|
+
// model is told.
|
|
54
64
|
{
|
|
55
|
-
id: 'dsh-dlp/config-
|
|
65
|
+
id: 'dsh-dlp/config-copilot-instructions',
|
|
56
66
|
version: 1,
|
|
57
67
|
match: 'path',
|
|
58
|
-
pattern: /(^|\/)(
|
|
68
|
+
pattern: /(^|\/)\.github\/(copilot-instructions\.md$|instructions\/)/i,
|
|
69
|
+
effect: 'standing instructions the editor feeds to every future session',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'dsh-dlp/config-agent-instructions',
|
|
73
|
+
version: 2,
|
|
74
|
+
// `CLAUDE.local.md` is the personal, git-ignored companion to `CLAUDE.md`
|
|
75
|
+
// and is read the same way, so covering one and not the other left the
|
|
76
|
+
// quieter of the two files unguarded.
|
|
77
|
+
pattern: /(^|\/)(CLAUDE(\.local)?|AGENTS|GEMINI|\.cursorrules|\.windsurfrules)(\.md)?$/i,
|
|
78
|
+
match: 'path',
|
|
59
79
|
effect: 'standing instructions every future session in this repository reads',
|
|
60
80
|
},
|
|
81
|
+
// `.claude/rules` belongs beside the other three: VS Code lists it as a
|
|
82
|
+
// workspace instruction location it detects and applies on its own
|
|
83
|
+
// ("Workspace (Claude format) `.claude/rules` folder"), so it is loaded by an
|
|
84
|
+
// editor a developer never configured for Claude.
|
|
61
85
|
{
|
|
62
86
|
id: 'dsh-dlp/config-agent-rules',
|
|
63
|
-
version:
|
|
87
|
+
version: 2,
|
|
64
88
|
match: 'path',
|
|
65
|
-
pattern: /(^|\/)\.(cursor|windsurf|continue)\/rules(\/|$)/i,
|
|
89
|
+
pattern: /(^|\/)\.(claude|cursor|windsurf|continue)\/rules(\/|$)/i,
|
|
66
90
|
effect: 'an always-apply rules file every future session in this repository reads',
|
|
67
91
|
},
|
|
92
|
+
// CVE-2026-46580. A prompt template is loaded into a later session the same
|
|
93
|
+
// way a rules file is, and the extension is what marks it for loading.
|
|
94
|
+
{
|
|
95
|
+
id: 'dsh-dlp/config-prompt-template',
|
|
96
|
+
version: 1,
|
|
97
|
+
match: 'path',
|
|
98
|
+
pattern: /(^|\/)\.prompts\/.*\.prompttemplate$/i,
|
|
99
|
+
effect: 'a prompt template a later session loads without the model asking for it',
|
|
100
|
+
},
|
|
68
101
|
{
|
|
69
102
|
id: 'dsh-dlp/config-mcp-manifest',
|
|
70
103
|
version: 1,
|
|
@@ -114,6 +147,23 @@ export const CONFIG_WRITE_RULES = [
|
|
|
114
147
|
pattern: /(^|\/)cordis[^/]*\.ya?ml$/i,
|
|
115
148
|
effect: 'a harness bundle manifest, which decides which plugins load',
|
|
116
149
|
},
|
|
150
|
+
// pnpm reads `registry`, `registries` and `namedRegistries` from
|
|
151
|
+
// `pnpm-workspace.yaml`, so the file decides which host the next install
|
|
152
|
+
// downloads packages from. pnpm's own documentation treats it as
|
|
153
|
+
// attacker-controlled for exactly that reason: since v11.5.3 it refuses to
|
|
154
|
+
// expand `${...}` in those settings, "Because `pnpm-workspace.yaml` is
|
|
155
|
+
// committed to the repository, expanding env variables in registry URLs could
|
|
156
|
+
// be exploited by a malicious repository to leak secrets from the environment
|
|
157
|
+
// to an attacker-controlled registry." A literal hostile URL is still obeyed.
|
|
158
|
+
// The `.npmrc` half of the same technique needs no rule here: it is on the
|
|
159
|
+
// guard floor as `dsh-dlp/path-npmrc`, where every call is denied.
|
|
160
|
+
{
|
|
161
|
+
id: 'dsh-dlp/config-pnpm-workspace',
|
|
162
|
+
version: 1,
|
|
163
|
+
match: 'path',
|
|
164
|
+
pattern: /(^|\/)pnpm-workspace\.ya?ml$/i,
|
|
165
|
+
effect: 'the pnpm workspace settings, which decide the registry the next install downloads packages from',
|
|
166
|
+
},
|
|
117
167
|
// CVE-2026-21852: a repo-local settings file setting `ANTHROPIC_BASE_URL`
|
|
118
168
|
// sends the user's own API key to whatever host it names. This is neither a
|
|
119
169
|
// 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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
255
|
-
+ ' to a denial. This tier abstains instead: a write to a
|
|
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
|
-
|
|
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/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,92 @@
|
|
|
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's scalar rendering, lowercased. */
|
|
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 argument key is matched under: lowercase, with the
|
|
58
|
+
* separators that distinguish `non_interactive`, `nonInteractive` and
|
|
59
|
+
* `non-interactive` removed.
|
|
60
|
+
* @param key - the key as the tool declared it.
|
|
61
|
+
* @returns the normalized spelling.
|
|
62
|
+
*/
|
|
63
|
+
export declare function normalizeArgumentKey(key: string): string;
|
|
64
|
+
/**
|
|
65
|
+
* The first rule any object inside the arguments satisfies.
|
|
66
|
+
*
|
|
67
|
+
* Both halves of a two-part rule must sit on the *same* object: an `apply` in
|
|
68
|
+
* one element of a batch and an `approvalPolicy` in another are two different
|
|
69
|
+
* requests, and pairing them across objects would report a call nobody made.
|
|
70
|
+
* @param args - the pending call's parsed arguments.
|
|
71
|
+
* @param rules - the rule table; defaults to {@link APPROVAL_SUPPRESSION_RULES}.
|
|
72
|
+
* @returns the first matching rule, or `undefined`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function matchApprovalSuppression(args: unknown, rules?: readonly ApprovalSuppressionRule[]): ApprovalSuppressionRule | undefined;
|
|
75
|
+
/** A call this tier wants a human to confirm, because the call asked not to be. */
|
|
76
|
+
export interface ApprovalSuppressionFinding {
|
|
77
|
+
readonly rule: ApprovalSuppressionRule;
|
|
78
|
+
/** Model- and user-facing text; names the tool, the rule and what the argument does. */
|
|
79
|
+
readonly reason: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether one call suppresses its own confirmation.
|
|
83
|
+
*
|
|
84
|
+
* A tool {@link isReadOnlyTool} classifies as query-only is left alone: it has
|
|
85
|
+
* nothing to confirm, so an argument switching a confirmation off changes
|
|
86
|
+
* nothing there.
|
|
87
|
+
* @param exec - the pending call.
|
|
88
|
+
* @param rules - the rule table; defaults to {@link APPROVAL_SUPPRESSION_RULES}.
|
|
89
|
+
* @returns the finding, or `undefined` to leave the call alone.
|
|
90
|
+
*/
|
|
91
|
+
export declare function evaluateApprovalSuppression(exec: Pick<ToolExecution, 'name' | 'arguments'>, rules?: readonly ApprovalSuppressionRule[]): ApprovalSuppressionFinding | undefined;
|
|
92
|
+
//# sourceMappingURL=approvals.d.ts.map
|
|
@@ -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
|
package/lib/types/index.d.ts
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
package/lib/types/policy.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.5.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>",
|