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 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`, `.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.
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
@@ -22,3 +22,4 @@
22
22
  remoteImageNeutralization: true
23
23
  redactTelemetryWorkspacePaths: true
24
24
  configWriteAsk: true
25
+ approvalSuppressionAsk: true
@@ -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
+ }
@@ -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-agent-instructions',
65
+ id: 'dsh-dlp/config-copilot-instructions',
56
66
  version: 1,
57
67
  match: 'path',
58
- pattern: /(^|\/)(CLAUDE|AGENTS|GEMINI|\.cursorrules|\.windsurfrules)(\.md)?$/i,
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: 1,
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. 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/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
@@ -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.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>",