dsh-dlp 0.5.0 → 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
@@ -27,8 +27,11 @@ built as an out-of-repo plugin.
27
27
  7. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
28
28
  `approval_mode: auto`, an `apply` whose approval is still pending. Both `ask` tiers are
29
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.
31
- Never the secret, never the path or command that matched. `dsh-dlp report` reads it back.
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.
32
35
 
33
36
  ## What this is not
34
37
 
@@ -44,9 +47,12 @@ Three limits worth knowing before you rely on it:
44
47
  - **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
45
48
  registered ahead of ours. `ctx.tools.guard()` is order-independent only because it has no allow
46
49
  arm.
47
- - **The shell-command arm is advisory pattern-matching.** It catches an unobfuscated
48
- `cat ~/.ssh/id_rsa` and nothing that tries one glob character, a `$(printf …)` reassembly or
49
- `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.**
50
56
  - **Detection is pattern-based.** No entropy rule (measured, not assumed: at a false-positive-free
51
57
  threshold the miss rate is 100% below 22 characters). Encoded forms pass. A homoglyph defeats
52
58
  every rule in this package.
@@ -108,11 +114,14 @@ switch a pass on. Any downgrade makes the whole file invalid.
108
114
  ```sh
109
115
  dsh-dlp report # everything in the audit sink
110
116
  dsh-dlp report --since 24h
111
- dsh-dlp report --kind guard-deny
117
+ dsh-dlp report --session <id>
118
+ dsh-dlp report --would-have # everything except the denials
112
119
  ```
113
120
 
114
- Every record carries a rule id, rule version, span offsets and a keyed hash — never the matched
115
- 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.
116
125
 
117
126
  [Audit record format →](https://charlotten7.github.io/dsh-dlp/audit.html)
118
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/lib/approvals.js CHANGED
@@ -30,7 +30,13 @@ import { isReadOnlyTool } from "./paths.js";
30
30
  * for the prompt.
31
31
  */
32
32
  const SUPPRESSING_TRUE = /^(?:true|yes|on|1)$/;
33
- /** Values of an approval-mode argument that name the absence of a prompt. */
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
+ */
34
40
  const SUPPRESSING_MODE = /^(?:auto|autoapprove|autoedit|never|none|bypass|fullauto|yolo)$/;
35
41
  /**
36
42
  * Arguments that turn off the human confirmation for the call carrying them.
@@ -51,7 +57,7 @@ export const APPROVAL_SUPPRESSION_RULES = [
51
57
  // CVE-2026-56075.
52
58
  {
53
59
  id: 'dsh-dlp/approval-mode-auto',
54
- version: 1,
60
+ version: 2,
55
61
  condition: { key: /^approval(?:mode|policy|setting)$/, value: SUPPRESSING_MODE },
56
62
  effect: 'an approval mode that approves on the model\'s behalf instead of asking',
57
63
  },
@@ -66,25 +72,33 @@ export const APPROVAL_SUPPRESSION_RULES = [
66
72
  },
67
73
  ];
68
74
  /**
69
- * The spelling one argument key is matched under: lowercase, with the
75
+ * The spelling one key or value is matched under: lowercase, with the
70
76
  * separators that distinguish `non_interactive`, `nonInteractive` and
71
- * `non-interactive` removed.
72
- * @param key - the key as the tool declared it.
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.
73
87
  * @returns the normalized spelling.
74
88
  */
75
- export function normalizeArgumentKey(key) {
76
- return key.toLowerCase().replace(/[_.-]/g, '');
89
+ export function normalizeArgumentToken(text) {
90
+ return text.toLowerCase().replace(/[_.-]/g, '');
77
91
  }
78
92
  /**
79
93
  * One argument value as a string, for the values a flag can take.
80
94
  * @param node - the value under one argument key.
81
- * @returns the lowercased rendering, or `undefined` for an object or a list.
95
+ * @returns the normalized rendering, or `undefined` for an object or a list.
82
96
  */
83
97
  function scalarValue(node) {
84
98
  if (typeof node === 'boolean' || typeof node === 'number')
85
99
  return String(node);
86
100
  if (typeof node === 'string')
87
- return node.trim().toLowerCase();
101
+ return normalizeArgumentToken(node.trim());
88
102
  return undefined;
89
103
  }
90
104
  /** Whether one object carries a key and value the condition describes. */
@@ -121,7 +135,7 @@ export function matchApprovalSuppression(args, rules = APPROVAL_SUPPRESSION_RULE
121
135
  for (const [key, value] of Object.entries(node)) {
122
136
  const scalar = scalarValue(value);
123
137
  if (scalar !== undefined)
124
- entries.set(normalizeArgumentKey(key), scalar);
138
+ entries.set(normalizeArgumentToken(key), scalar);
125
139
  }
126
140
  found = rules.find(rule => satisfies(entries, rule.condition)
127
141
  && (rule.alongside === undefined || satisfies(entries, rule.alongside)));
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)
@@ -51,9 +51,11 @@ export const CONFIG_WRITE_RULES = [
51
51
  },
52
52
  {
53
53
  id: 'dsh-dlp/config-agent-hooks',
54
- version: 1,
54
+ version: 2,
55
55
  match: 'path',
56
- 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,
57
59
  effect: 'an agent hook, which runs on a session event without the model asking for it',
58
60
  },
59
61
  // Copilot reads these without any agent asking it to: VS Code documents
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 },
@@ -26,7 +26,7 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
26
26
  export interface ArgumentCondition {
27
27
  /** Matched against the key lowercased with `_`, `-` and `.` removed. */
28
28
  readonly key: RegExp;
29
- /** Matched against the value's scalar rendering, lowercased. */
29
+ /** Matched against the value under the same normalization as the key. */
30
30
  readonly value: RegExp;
31
31
  }
32
32
  /** One argument, or pair of arguments, that suppresses a confirmation. */
@@ -54,13 +54,21 @@ export interface ApprovalSuppressionRule {
54
54
  */
55
55
  export declare const APPROVAL_SUPPRESSION_RULES: readonly ApprovalSuppressionRule[];
56
56
  /**
57
- * The spelling one argument key is matched under: lowercase, with the
57
+ * The spelling one key or value is matched under: lowercase, with the
58
58
  * separators that distinguish `non_interactive`, `nonInteractive` and
59
- * `non-interactive` removed.
60
- * @param key - the key as the tool declared it.
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.
61
69
  * @returns the normalized spelling.
62
70
  */
63
- export declare function normalizeArgumentKey(key: string): string;
71
+ export declare function normalizeArgumentToken(text: string): string;
64
72
  /**
65
73
  * The first rule any object inside the arguments satisfies.
66
74
  *
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.5.0",
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>",