dsh-dlp 0.3.0 → 0.4.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
@@ -3,7 +3,7 @@
3
3
  Data-loss prevention for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),
4
4
  built as an out-of-repo plugin.
5
5
 
6
- It does six things:
6
+ It does seven things:
7
7
 
8
8
  1. **Denies credential-file access and secrets bound for the network** — unconditionally, from
9
9
  `ctx.tools.guard()`. It tests the path-typed arguments of a call against a table of
@@ -19,7 +19,12 @@ It does six things:
19
19
  plugin rewrote after the session log recorded it. Both are partial mitigations for defects
20
20
  in the harness rather than in your configuration —
21
21
  [see below](#mitigations-for-defects-in-the-harness-itself), including what they do not close.
22
- 6. **Writes an audit record for every decision** to its own sinkrule id, rule version,
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. This tier prompts rather than denying, and is neutralizable;
26
+ [see below](#behaviour-changing-config-paths).
27
+ 7. **Writes an audit record for every decision** to its own sink — rule id, rule version,
23
28
  offsets, and a keyed hash. Never the secret, and never the path or command that matched.
24
29
  `dsh-dlp report` reads that sink back.
25
30
 
@@ -248,7 +253,7 @@ checkout, build first and add the tarball:
248
253
  ```sh
249
254
  git clone https://github.com/CharlotteN7/dsh-dlp && cd dsh-dlp
250
255
  pnpm install && pnpm run build && pnpm pack
251
- dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
256
+ dsh plugin --profile <name> add ./dsh-dlp-0.3.0.tgz
252
257
  ```
253
258
 
254
259
  ## Configure
@@ -266,6 +271,7 @@ dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
266
271
  telemetryRedaction: true
267
272
  remoteImageNeutralization: true
268
273
  redactTelemetryWorkspacePaths: true
274
+ configWriteAsk: true
269
275
  ```
270
276
 
271
277
  `redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out
@@ -295,7 +301,7 @@ addCredentialPaths:
295
301
  addEgressTools: [acme_publish]
296
302
  raiseSeverity:
297
303
  dsh-dlp/secret-assignment: high
298
- enable: [telemetryRedaction]
304
+ enable: [telemetryRedaction, configWriteAsk]
299
305
  ```
300
306
 
301
307
  Any other key, and any downgrade, makes the **whole file invalid**: it is reported on
@@ -328,6 +334,22 @@ directories (but not `.env.example`), anything under `.ssh/`,
328
334
  documentation extensions are excluded from that last rule, so `src/auth/token.ts` stays
329
335
  readable.
330
336
 
337
+ **Coding-agent and infrastructure credential stores**, which IronWorm's 44 packages and
338
+ SANDWORM_MODE name verbatim: an `auth.json` under `.codex/`, `Cursor/`, `.composer/`,
339
+ `.windsurf/`, `.continue/`, `.aider/`, `.claude/` or `.gemini/`; an `mcp.json` under any of the
340
+ same directories, because an MCP manifest carries each server's `env` and that is where its API
341
+ keys are written; Cursor's `state.vscdb` session database; anything under `Library/Keychains/`;
342
+ `*.tfvars` and `terraform.tfstate`, which hold provider credentials in plaintext.
343
+
344
+ **A home-level agent settings file is denied for writing only.** `~/.claude/settings.json`,
345
+ `~/.gemini/settings.json` and the equivalents for Codex, Cursor, Windsurf and Continue decide
346
+ how every future session in every repository behaves — this is where the Miasma worm put its
347
+ `SessionStart` hooks — so writing one is on the floor. Reading one is ordinary work, since a
348
+ user asking why their agent behaves a certain way is a normal request, so the rule is lifted for
349
+ a tool that provably cannot change anything. The **repository-local** copies of those same file
350
+ names are a different question with a different answer: see
351
+ [behaviour-changing config paths](#behaviour-changing-config-paths) below.
352
+
331
353
  Also denied for every tool: this plugin's own `redactionKeyFile` and `auditLog`.
332
354
 
333
355
  **`$DSH_HOME` is split by direction.** Every *write* under the harness home is denied, for
@@ -382,6 +404,69 @@ need, or use a path that is not a credential store.
382
404
 
383
405
  ---
384
406
 
407
+ ## Behaviour-changing config paths
408
+
409
+ Everything above governs **reads**. The dominant technique of 2026 is the opposite: the agent
410
+ *writes* a file that changes what happens next time. The Miasma worm put `SessionStart` hooks in
411
+ `.claude/settings.json` and `.gemini/settings.json`, an always-apply `.cursor/rules/setup.mdc`,
412
+ a `folderOpen` task in `.vscode/tasks.json` and a hijacked `npm test` into `Azure/durabletask`;
413
+ GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples over it, 39 of them
414
+ inside 38 seconds. See also CVE-2025-53773, CVE-2026-25725, CVE-2026-33068, CVE-2026-48124,
415
+ CVE-2026-26268 and CVE-2025-59041.
416
+
417
+ A write to one of these **asks the user first**:
418
+
419
+ | Rule | Paths |
420
+ |---|---|
421
+ | `config-agent-settings` | `.claude/settings*.json`, and the same under `.gemini/`, `.codex/`, `.cursor/`, `.windsurf/`, `.continue/` |
422
+ | `config-agent-hooks` | `.claude/hooks/**` and the same under the other agent directories |
423
+ | `config-agent-instructions` | `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursorrules`, `.windsurfrules` |
424
+ | `config-agent-rules` | `.cursor/rules/**`, `.windsurf/rules/**`, `.continue/rules/**` |
425
+ | `config-mcp-manifest` | `.mcp.json` |
426
+ | `config-editor-tasks` | `.vscode/settings.json`, `.vscode/tasks.json`, `.vscode/launch.json` |
427
+ | `config-git` | `.git/config`, `.git/hooks/**` |
428
+ | `config-git-hooks-managed` | `.husky/**` |
429
+ | `config-ci-workflow` | `.github/workflows/**`, `.gitlab-ci.yml`, `.circleci/**` |
430
+ | `config-shell-rc` | `.bashrc`, `.bash_profile`, `.profile`, `.zshrc`, `.zprofile`, `.zshenv`, `.kshrc`, `config.fish`, … |
431
+ | `config-harness-bundle` | `cordis*.yml` |
432
+ | `config-api-base-url` | not a path — content setting a `*_BASE_URL` or `*_API_BASE` to an `http(s)` URL |
433
+
434
+ The last row is CVE-2026-21852: a repo-local settings file that sets `ANTHROPIC_BASE_URL` sends
435
+ the user's own API key to whatever host it names. That is neither a path nor a secret — it is a
436
+ config key whose *value* redirects a credential — so it is matched against the bytes the call
437
+ would write rather than against where they would go.
438
+
439
+ **Rules match by name, so creating a file is covered as well as changing one.** CVE-2026-25725
440
+ worked precisely because the path did not exist yet and was therefore writable with nothing to
441
+ prompt about.
442
+
443
+ **This tier is `ask`, and it is therefore neutralizable — unlike the floor.** That is deliberate
444
+ and it is the important sentence in this section. A developer asks the agent to edit `CLAUDE.md`
445
+ or add a workflow constantly; the guard floor is deny-only and non-overridable by design, so a
446
+ rule with that false-positive rate must not go there. It lives at `tools/pre-execute`, which
447
+ means a listener registered ahead of ours can return without calling `next()` and switch the
448
+ whole tier off. Treat it as a prompt, not as a control.
449
+
450
+ **A call the floor already denies is left to the floor**, and no prompt appears for it. Any
451
+ non-allow decision at `tools/pre-execute` skips guards entirely, so asking about a call the
452
+ guard would deny would replace an unconditional denial with a prompt a user can grant. That is
453
+ also why `~/.claude/settings.json` and a repository's own `.claude/settings.json` behave
454
+ differently: the first is on the floor, the second is a prompt.
455
+
456
+ Two more limits worth stating:
457
+
458
+ - **A shell redirection is not covered.** Only path-typed arguments are tested, and unlike the
459
+ floor the command line is not tokenised: a shell command cannot be told apart from a *read* of
460
+ the same file, and prompting on `cat .github/workflows/ci.yml` is exactly the false positive
461
+ that gets a tier switched off.
462
+ - **With no approval service mounted, the tier abstains rather than denying.** The registry
463
+ resolves an `ask` through `ctx.get('approval')` and degrades to a *denial* when nothing is
464
+ composed — which would turn this tier into the silent hard deny it was designed not to be. It
465
+ reports once on `process.stderr` and `ctx.logger` and lets the call through. `configWriteAsk:
466
+ false` turns it off entirely.
467
+
468
+ ---
469
+
385
470
  ## What gets redacted
386
471
 
387
472
  A redacted region becomes:
@@ -430,10 +515,16 @@ the redacted copy.
430
515
  Two tiers:
431
516
 
432
517
  - **Tier 1**, synchronous and owned by this package: prefix-anchored token formats (AWS,
433
- GitHub, Slack, Stripe, OpenAI, Anthropic, Google, npm), PEM private-key blocks, JWTs,
434
- credential-bearing URLs, Slack/Discord/Teams webhook URLs, and high-signal secret
435
- assignments. This is the tier the guard and the telemetry listener use, because both of
436
- those seams are synchronous, and it is never capped.
518
+ GitHub, GitLab, Slack, Stripe, OpenAI, OpenRouter, Anthropic, Google API keys and
519
+ `GOCSPX-` OAuth client secrets, npm, HuggingFace, Groq, xAI, Databricks, SendGrid,
520
+ Supabase, Notion), PEM private-key blocks, JWTs, credential-bearing URLs,
521
+ Slack/Discord/Teams webhook URLs, and high-signal secret assignments. This is the tier the
522
+ guard and the telemetry listener use, because both of those seams are synchronous, and it is
523
+ never capped.
524
+
525
+ Prefix-anchored is the whole criterion for being in this tier, and the reason the table keeps
526
+ growing rather than deferring to tier 2 is the line below: **the telemetry seam cannot reach
527
+ tier 2**, so a format missing from tier 1 is exported in the clear when telemetry is on.
437
528
  - **Tier 2**, [`@secretlint/core`](https://github.com/secretlint/secretlint) with the
438
529
  recommended preset — 28 maintained rules, in-process, no subprocess. Used at
439
530
  `tools/pre-execute` and `tools/post-execute`, the two seams that can await. **The telemetry
@@ -456,7 +547,9 @@ session titles — and never on the tool-result path.
456
547
  | Bidi overrides and isolates | `U+202A–U+202E`, `U+2066–U+2069` | replaced |
457
548
  | Zero-width | `U+200B–U+200D`, `U+2060`, `U+FEFF` | counted only |
458
549
  | Bidi marks | `U+061C`, `U+200E–U+200F` | counted only |
459
- | Variation selectors | `U+FE00–U+FE0F`, `U+E0100–U+E01EF` | counted only |
550
+ | Variation selectors, 1–3 in a row | `U+FE00–U+FE0F`, `U+E0100–U+E01EF` | counted only |
551
+ | Variation selectors, 4 or more in a row | the same class | replaced |
552
+ | Terminal control sequences | CSI, OSC, DCS, SOS, PM, APC, other `ESC` forms, C1 `U+0080–U+009F` | counted in tool results, **replaced in the audit sink** |
460
553
 
461
554
  The first two have no legitimate use in tool output — the Tags block is a full invisible ASCII
462
555
  alphabet, which is what makes it the standard carrier for a hidden instruction. The last three
@@ -469,6 +562,30 @@ character is never turned into a denial. A replaced run becomes an ordinary plac
469
562
  unlike a secret, is replaced exactly: an invisible character is not widened to its surrounding
470
563
  delimiters, so the visible word it hid inside survives.
471
564
 
565
+ **Variation selectors are split by run length.** One selector picks a glyph — VS15/VS16 after a
566
+ base character, one selector after one ideograph in an Ideographic Variation Sequence — so an
567
+ isolated occurrence stays counted-only. A run of four or more is not glyph selection: it is a
568
+ byte string wearing the same code points, which is how GlassWorm hid executable JavaScript
569
+ across five waves, 35,800 installs, 300+ repositories and the first MCP package compromises. An
570
+ emoji ZWJ sequence separates its selectors with a joiner, so no legitimate sequence produces a
571
+ run at all; four is a conservative floor, and a real payload is hundreds of selectors long.
572
+
573
+ **Terminal control sequences are split by lane rather than by class.** A tool result carrying
574
+ SGR colour codes is the normal output of `git diff`, `rg` and `pytest`, so on that lane the
575
+ class is counted and left alone. On the lane that ends in an audit record it is **replaced**
576
+ with `[REDACTED:dsh-dlp:control-sequence]`, because a record is evidence and evidence must not
577
+ be able to rewrite itself: `JSON.stringify` escapes the byte in the file, but `dsh-dlp report`,
578
+ `jq -r` and every log viewer parse it back into a live escape, so a tool registered under a name
579
+ containing `ESC [ 1 A ESC [ 2 K` could overwrite the audit line describing it. The whole CSI
580
+ form is matched, not the SGR subset, along with OSC, DCS, SOS, PM, APC, the other escape forms
581
+ and the 8-bit C1 controls; an unterminated OSC is matched to the end of the string, because that
582
+ is how much of the display it would swallow. A repo-local `policyFile` whose rule `id` carries
583
+ one is rejected outright, since a rule id is quoted in the denial the user reads.
584
+
585
+ Not covered: a bare `\r`, `\b` or `\f` can still overprint a line on a terminal. Those have
586
+ ordinary uses in tool output and are escaped by `JSON.stringify` in the sink; the escape-driven
587
+ forms above are the ones with no benign use in a record.
588
+
472
589
  **A homoglyph defeats all of this**, and every other rule in this plugin. A Cyrillic `а` in
473
590
  `аdmin` is a normal, visible, legitimately-encoded character; detecting it means UTS #39
474
591
  confusable tables, which is a data set and a different cost class. This plugin does not attempt
@@ -530,8 +647,10 @@ its own identity.
530
647
  }
531
648
  ```
532
649
 
533
- `kind` is one of `guard-deny`, `pre-execute-deny`, `execution-mutation`, `result-redaction`,
534
- `telemetry-redaction`, `assistant-image-neutralized`. An `execution-mutation` record carries
650
+ `kind` is one of `guard-deny`, `pre-execute-deny`, `pre-execute-ask`, `execution-mutation`,
651
+ `result-redaction`, `telemetry-redaction`, `assistant-image-neutralized`. A `pre-execute-ask`
652
+ record carries a top-level `ruleId` instead of `spans`: the finding is that a path names a
653
+ behaviour-changing file, not that any region of it matched. An `execution-mutation` record carries
535
654
  `mutatedFields` and, when a tool substitution happened, the `originalTool` the log recorded. An
536
655
  `assistant-image-neutralized` record carries `host` — the hostname of the blocked destination
537
656
  and nothing else from the URL.
package/SECURITY.md CHANGED
@@ -4,11 +4,12 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---|---|
7
- | 0.1.x | yes |
8
- | < 0.1 | no |
7
+ | 0.3.x | yes |
8
+ | < 0.3 | no |
9
9
 
10
- Only the latest published `0.1.x` receives fixes. There is no long-term-support branch while
11
- the package is pre-1.0.
10
+ Only the latest published `0.3.x` receives fixes. There is no long-term-support branch while
11
+ the package is pre-1.0: each minor supersedes the one before it, and a fix ships as the next
12
+ `0.3.x` patch or, if the minor has already moved on, as the next minor.
12
13
 
13
14
  ## Reporting a vulnerability
14
15
 
@@ -44,4 +45,11 @@ These do count, and we want to hear about them:
44
45
  - a secret surviving into the session log through a `tools/post-execute` arm;
45
46
  - a repo-local `policyFile` loosening any part of the floor, executing code, or stalling the
46
47
  agent;
47
- - any way to make the guard abstain that does not require executing code.
48
+ - any way to make the guard abstain that does not require executing code;
49
+ - a terminal control sequence, or any other forgeable bytes, reaching the audit sink or a
50
+ denial the user reads.
51
+
52
+ The `ask` tier for behaviour-changing config paths is **not** part of the floor and is
53
+ documented as neutralizable: it lives at `tools/pre-execute`, so a listener registered ahead of
54
+ it disables it. A missed path there is a gap worth reporting; the fact that another plugin can
55
+ switch the tier off is a stated design limit, not a vulnerability.
package/cordis.patch.yml CHANGED
@@ -21,3 +21,4 @@
21
21
  telemetryRedaction: true
22
22
  remoteImageNeutralization: true
23
23
  redactTelemetryWorkspacePaths: true
24
+ configWriteAsk: true
package/lib/cli.js CHANGED
@@ -24,18 +24,22 @@ function stringField(record, key) {
24
24
  const value = record[key];
25
25
  return typeof value === 'string' ? value : undefined;
26
26
  }
27
- /** Rule ids named by a record's spans, in file order and without repeats. */
27
+ /**
28
+ * Rule ids a record names, in file order and without repeats: one per span,
29
+ * plus the top-level `ruleId` a decision with no matched region carries.
30
+ */
28
31
  function ruleIdsOf(record) {
32
+ const named = stringField(record, 'ruleId');
29
33
  const spans = record['spans'];
30
34
  if (!Array.isArray(spans))
31
- return [];
35
+ return named === undefined ? [] : [named];
32
36
  const ids = spans.flatMap((span) => {
33
37
  if (typeof span !== 'object' || span === null)
34
38
  return [];
35
39
  const ruleId = span['ruleId'];
36
40
  return typeof ruleId === 'string' ? [ruleId] : [];
37
41
  });
38
- return [...new Set(ids)];
42
+ return [...new Set(named === undefined ? ids : [named, ...ids])];
39
43
  }
40
44
  /** Invisible-character counts a record carries, keeping only numeric entries. */
41
45
  function unicodeOf(record) {
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The `ask` tier: files whose contents decide how the agent, the editor or CI
3
+ * behaves next time, and the one config *key* whose value redirects a
4
+ * credential.
5
+ *
6
+ * This tier is deliberately **not** on the guard floor, and that is the whole
7
+ * design. `ctx.tools.guard()` has no `ask` arm and cannot be overridden, so a
8
+ * rule that lands there must be one a developer never legitimately trips. A
9
+ * developer asks the agent to edit `CLAUDE.md`, add a `.github/workflows`
10
+ * job or extend `.vscode/settings.json` constantly. Putting those on an
11
+ * unoverridable floor produces a plugin that gets uninstalled, which removes
12
+ * the floor as well.
13
+ *
14
+ * The cost is stated rather than hidden: `tools/pre-execute` is neutralizable.
15
+ * A listener registered ahead of this one can return without calling `next()`
16
+ * and this tier never runs. Only the guard floor is order-independent.
17
+ * @module dsh-dlp/config-writes
18
+ */
19
+ import { isReadOnlyTool, normalizeCandidatePath, pathArguments } from "./paths.js";
20
+ import { nestedStrings } from "./redaction.js";
21
+ /**
22
+ * Paths whose contents change future behaviour, and the config key whose value
23
+ * redirects credentials.
24
+ *
25
+ * Every path here was used in the wild. The Miasma worm wrote `SessionStart`
26
+ * hooks into `.claude/settings.json` and `.gemini/settings.json`, an
27
+ * always-apply `.cursor/rules/setup.mdc`, a `folderOpen` task into
28
+ * `.vscode/tasks.json`, and a hijacked `npm test` into `Azure/durabletask`;
29
+ * GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples
30
+ * over it, 39 of them inside 38 seconds. See also CVE-2025-53773,
31
+ * CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
32
+ * CVE-2025-59041.
33
+ *
34
+ * The rules match by name, never by what is on disk, so a file the call is
35
+ * about to *create* is matched exactly like one it would change:
36
+ * CVE-2026-25725 worked precisely because the path did not exist yet and was
37
+ * therefore writable without any prompt.
38
+ */
39
+ export const CONFIG_WRITE_RULES = [
40
+ {
41
+ id: 'dsh-dlp/config-agent-settings',
42
+ version: 1,
43
+ match: 'path',
44
+ pattern: /(^|\/)\.(claude|gemini|codex|cursor|windsurf|continue)\/settings[^/]*\.json$/i,
45
+ effect: 'agent settings, which can register hooks that run on every future session',
46
+ },
47
+ {
48
+ id: 'dsh-dlp/config-agent-hooks',
49
+ version: 1,
50
+ match: 'path',
51
+ pattern: /(^|\/)\.(claude|gemini|codex|windsurf|continue)\/hooks(\/|$)/i,
52
+ effect: 'an agent hook, which runs on a session event without the model asking for it',
53
+ },
54
+ {
55
+ id: 'dsh-dlp/config-agent-instructions',
56
+ version: 1,
57
+ match: 'path',
58
+ pattern: /(^|\/)(CLAUDE|AGENTS|GEMINI|\.cursorrules|\.windsurfrules)(\.md)?$/i,
59
+ effect: 'standing instructions every future session in this repository reads',
60
+ },
61
+ {
62
+ id: 'dsh-dlp/config-agent-rules',
63
+ version: 1,
64
+ match: 'path',
65
+ pattern: /(^|\/)\.(cursor|windsurf|continue)\/rules(\/|$)/i,
66
+ effect: 'an always-apply rules file every future session in this repository reads',
67
+ },
68
+ {
69
+ id: 'dsh-dlp/config-mcp-manifest',
70
+ version: 1,
71
+ match: 'path',
72
+ pattern: /(^|\/)\.mcp\.json$/i,
73
+ effect: 'the MCP manifest, which decides which servers the agent starts and with what environment',
74
+ },
75
+ {
76
+ id: 'dsh-dlp/config-editor-tasks',
77
+ version: 1,
78
+ match: 'path',
79
+ pattern: /(^|\/)\.vscode\/(settings|tasks|launch)\.json$/i,
80
+ effect: 'editor configuration, which can run a task the moment the folder is opened',
81
+ },
82
+ {
83
+ id: 'dsh-dlp/config-git',
84
+ version: 1,
85
+ match: 'path',
86
+ pattern: /(^|\/)\.git\/(config$|hooks(\/|$))/i,
87
+ effect: 'git configuration or a git hook, which runs on the next commit, checkout or push',
88
+ },
89
+ {
90
+ id: 'dsh-dlp/config-git-hooks-managed',
91
+ version: 1,
92
+ match: 'path',
93
+ pattern: /(^|\/)\.husky(\/|$)/i,
94
+ effect: 'a managed git hook, which runs on the next commit or push',
95
+ },
96
+ {
97
+ id: 'dsh-dlp/config-ci-workflow',
98
+ version: 1,
99
+ match: 'path',
100
+ pattern: /(^|\/)\.(github\/workflows|gitlab-ci\.yml|circleci)(\/|$)/i,
101
+ effect: 'a CI workflow, which runs on the shared runner with the repository\'s secrets',
102
+ },
103
+ {
104
+ id: 'dsh-dlp/config-shell-rc',
105
+ version: 1,
106
+ match: 'path',
107
+ pattern: /(^|\/)(\.bashrc|\.bash_profile|\.bash_login|\.bash_logout|\.profile|\.zshrc|\.zprofile|\.zshenv|\.zlogin|\.kshrc|config\.fish)$/i,
108
+ effect: 'a shell startup file, which runs on every future shell this agent opens',
109
+ },
110
+ {
111
+ id: 'dsh-dlp/config-harness-bundle',
112
+ version: 1,
113
+ match: 'path',
114
+ pattern: /(^|\/)cordis[^/]*\.ya?ml$/i,
115
+ effect: 'a harness bundle manifest, which decides which plugins load',
116
+ },
117
+ // CVE-2026-21852: a repo-local settings file setting `ANTHROPIC_BASE_URL`
118
+ // sends the user's own API key to whatever host it names. This is neither a
119
+ // path nor a secret — it is a key whose *value* redirects a credential — so
120
+ // it is matched against what would be written rather than against where.
121
+ {
122
+ id: 'dsh-dlp/config-api-base-url',
123
+ version: 1,
124
+ match: 'content',
125
+ pattern: /\b[A-Z][A-Z0-9_]*(?:_BASE_URL|_API_BASE)\b["']?\s*[=:]\s*["']?\s*https?:\/\//,
126
+ effect: 'a provider base URL, which sends the credential for that provider to whatever host it names',
127
+ },
128
+ ];
129
+ /**
130
+ * Argument keys whose values are the bytes a call would write.
131
+ *
132
+ * Deliberately separate from the floor's path-typed keys: the floor must never
133
+ * run its path table over file content, and this tier must run its one content
134
+ * rule over nothing else.
135
+ */
136
+ export const CONTENT_ARGUMENT_KEYS = new Set([
137
+ 'content', 'contents', 'text', 'file_text', 'fileText',
138
+ 'new_string', 'newString', 'new_str', 'replacement', 'body',
139
+ ]);
140
+ /**
141
+ * The strings one call would write.
142
+ * @param args - the pending call's parsed arguments.
143
+ * @returns every string under a content-typed key, at any depth.
144
+ */
145
+ export function contentArguments(args) {
146
+ const found = [];
147
+ const walk = (node) => {
148
+ if (Array.isArray(node)) {
149
+ for (const item of node)
150
+ walk(item);
151
+ return;
152
+ }
153
+ if (typeof node !== 'object' || node === null)
154
+ return;
155
+ for (const [key, value] of Object.entries(node)) {
156
+ if (CONTENT_ARGUMENT_KEYS.has(key))
157
+ found.push(...nestedStrings(value));
158
+ else
159
+ walk(value);
160
+ }
161
+ };
162
+ walk(args);
163
+ return found;
164
+ }
165
+ /**
166
+ * Prompt text for one behaviour-changing write.
167
+ *
168
+ * The path is not quoted, for the same reason the floor never quotes one: this
169
+ * string is model-visible and a path carries tenant and customer names. What
170
+ * the user needs in order to answer is which tool, which rule, and what the
171
+ * file does.
172
+ */
173
+ function configWriteReason(toolName, rule) {
174
+ return `dsh-dlp is asking before ${JSON.stringify(toolName)} writes ${rule.effect} (rule ${rule.id}). `
175
+ + 'This kind of file changes what happens on a later session, commit or CI run rather than now, so it is worth '
176
+ + 'one look. Approve it if you asked for this change; decline it if you did not.';
177
+ }
178
+ /**
179
+ * Decide whether one call should be confirmed by a human.
180
+ *
181
+ * Only calls that can change something are examined: a tool
182
+ * {@link isReadOnlyTool} classifies as query-only cannot write a hook. Shell
183
+ * command lines are deliberately **not** tokenised here, unlike in the floor:
184
+ * a command line cannot be told apart from a read of the same path, and
185
+ * prompting on `cat .github/workflows/ci.yml` is exactly the false positive
186
+ * that gets a tier switched off. A shell redirection into one of these files
187
+ * is therefore not covered, which README.md says beside the feature.
188
+ * @param exec - the pending call.
189
+ * @param rules - the rule table; defaults to {@link CONFIG_WRITE_RULES}.
190
+ * @returns the finding, or `undefined` to leave the call alone.
191
+ */
192
+ export function evaluateConfigWrite(exec, rules = CONFIG_WRITE_RULES) {
193
+ if (isReadOnlyTool(exec.name))
194
+ return undefined;
195
+ const targets = pathArguments(exec.arguments).filter(argument => !argument.shell);
196
+ for (const rule of rules) {
197
+ if (rule.match !== 'path')
198
+ continue;
199
+ for (const target of targets) {
200
+ if (rule.pattern.test(normalizeCandidatePath(target.text))) {
201
+ return { rule, reason: configWriteReason(exec.name, rule) };
202
+ }
203
+ }
204
+ }
205
+ const written = contentArguments(exec.arguments);
206
+ for (const rule of rules) {
207
+ if (rule.match !== 'content')
208
+ continue;
209
+ for (const text of written) {
210
+ if (rule.pattern.test(text))
211
+ return { rule, reason: configWriteReason(exec.name, rule) };
212
+ }
213
+ }
214
+ return undefined;
215
+ }
package/lib/detectors.js CHANGED
@@ -36,6 +36,11 @@ export const DENY_SEVERITY = 'high';
36
36
  * delimiters make a match structurally unambiguous, plus PEM blocks and
37
37
  * credential-bearing URLs. Anything requiring entropy heuristics is left to
38
38
  * tier 2, where a false positive costs a redaction rather than a denial.
39
+ *
40
+ * Prefix-anchored is the whole membership criterion, and the reason this table
41
+ * keeps growing rather than deferring to tier 2: the `session-telemetry/record`
42
+ * waterfall is synchronous and cannot reach tier 2 at all, so a format missing
43
+ * here is exported in the clear when telemetry is on.
39
44
  */
40
45
  export const SYNC_RULES = [
41
46
  { id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
@@ -44,9 +49,23 @@ export const SYNC_RULES = [
44
49
  { id: 'dsh-dlp/slack-token', version: 1, severity: 'critical', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
45
50
  { id: 'dsh-dlp/stripe-secret-key', version: 1, severity: 'critical', pattern: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/g },
46
51
  { id: 'dsh-dlp/anthropic-api-key', version: 1, severity: 'critical', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
52
+ // Ahead of the OpenAI rule, whose `sk-` prefix also covers this shape: two
53
+ // detections over the same span merge into one placeholder attributed to
54
+ // whichever rule the table reached first, and the specific rule is the
55
+ // useful attribution.
56
+ { id: 'dsh-dlp/openrouter-api-key', version: 1, severity: 'critical', pattern: /\bsk-or-v1-[0-9a-f]{64}\b/g },
47
57
  { id: 'dsh-dlp/openai-api-key', version: 1, severity: 'critical', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
48
58
  { id: 'dsh-dlp/google-api-key', version: 1, severity: 'critical', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
49
59
  { id: 'dsh-dlp/npm-token', version: 1, severity: 'critical', pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
60
+ { id: 'dsh-dlp/gitlab-token', version: 1, severity: 'critical', pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g },
61
+ { id: 'dsh-dlp/huggingface-token', version: 1, severity: 'critical', pattern: /\bhf_[A-Za-z0-9]{34,}/g },
62
+ { id: 'dsh-dlp/groq-api-key', version: 1, severity: 'critical', pattern: /\bgsk_[A-Za-z0-9]{40,}/g },
63
+ { id: 'dsh-dlp/xai-api-key', version: 1, severity: 'critical', pattern: /\bxai-[A-Za-z0-9]{32,}/g },
64
+ { id: 'dsh-dlp/google-oauth-client-secret', version: 1, severity: 'critical', pattern: /\bGOCSPX-[A-Za-z0-9_-]{24,}/g },
65
+ { id: 'dsh-dlp/databricks-token', version: 1, severity: 'critical', pattern: /\bdapi[0-9a-f]{32}(?:-\d+)?\b/g },
66
+ { id: 'dsh-dlp/sendgrid-api-key', version: 1, severity: 'critical', pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
67
+ { id: 'dsh-dlp/supabase-service-key', version: 1, severity: 'critical', pattern: /\bsbp_[0-9a-f]{40}\b/g },
68
+ { id: 'dsh-dlp/notion-token', version: 1, severity: 'critical', pattern: /\bntn_[A-Za-z0-9]{40,}/g },
50
69
  { id: 'dsh-dlp/private-key-block', version: 1, severity: 'critical', pattern: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g },
51
70
  { id: 'dsh-dlp/json-web-token', version: 1, severity: 'high', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
52
71
  { id: 'dsh-dlp/credential-url', version: 1, severity: 'high', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]+@[^\s/]+/gi },
@@ -58,13 +77,95 @@ export const SYNC_RULES = [
58
77
  { id: 'dsh-dlp/teams-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/[A-Za-z0-9.-]*webhook\.office\.com\/webhookb2\/[A-Za-z0-9@/_-]{10,}/g },
59
78
  { id: 'dsh-dlp/secret-assignment', version: 1, severity: 'medium', pattern: /\b(?:api[_-]?key|secret[_-]?key|client[_-]?secret|password|passwd|access[_-]?token|auth[_-]?token)\b\s*[=:]\s*["']?[A-Za-z0-9/+=_-]{16,}["']?/gi },
60
79
  ];
61
- /** Build one class's run pattern from its ranges, so the two cannot drift apart. */
62
- function unicodeRule(id, action, ranges) {
63
- return { id, version: 1, severity: 'medium', action, ranges, pattern: new RegExp(`[${ranges}]+`, 'gu') };
80
+ /**
81
+ * Build one class's run pattern from its ranges, so the two cannot drift apart.
82
+ * @param id - the rule's identity.
83
+ * @param action - what the scan does with a match.
84
+ * @param ranges - the class's ranges as a character-class body.
85
+ * @param quantifier - applied to the class; the default matches a whole run.
86
+ * @param wholeRun - whether a match must be a complete run rather than part of a longer one.
87
+ * @returns the rule.
88
+ */
89
+ function unicodeRule(id, action, ranges, quantifier = '+', wholeRun = false) {
90
+ const source = wholeRun
91
+ ? `(?<![${ranges}])[${ranges}]${quantifier}(?![${ranges}])`
92
+ : `[${ranges}]${quantifier}`;
93
+ return { id, version: 1, severity: 'medium', action, ranges, pattern: new RegExp(source, 'gu') };
64
94
  }
95
+ /** Variation-selector code points, shared by the isolated rule and the run rule. */
96
+ const VARIATION_SELECTORS = String.raw `\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}`;
97
+ /**
98
+ * Consecutive variation selectors at which the run stops being glyph selection
99
+ * and starts being a payload.
100
+ *
101
+ * One selector picks a glyph: VS15/VS16 after a base character, one selector
102
+ * after one ideograph in an Ideographic Variation Sequence. Two in a row have
103
+ * no standard meaning — an emoji ZWJ sequence separates its selectors with a
104
+ * joiner, so a run stays at one — and four leaves no plausible reading but
105
+ * "these are bytes". GlassWorm encoded executable JavaScript one byte per
106
+ * selector across five waves, 35,800 installs and 300+ repositories, so a real
107
+ * payload is hundreds of selectors long and 4 is a conservative floor rather
108
+ * than a tight one.
109
+ */
110
+ const VARIATION_SELECTOR_RUN = 4;
111
+ /**
112
+ * One terminal control sequence: the full CSI form, not only the SGR colour
113
+ * subset, plus the string-introducer families and the 8-bit C1 equivalents.
114
+ *
115
+ * Each alternative in order: an OSC/DCS/SOS/PM/APC introducer and its body up
116
+ * to a string terminator that may never arrive; a complete CSI — parameter
117
+ * bytes, intermediate bytes, one final byte; any other escape sequence; and a
118
+ * lone escape or C1 control that introduces nothing.
119
+ *
120
+ * Terminating at end of input matters: an unterminated OSC swallows everything
121
+ * a terminal prints after it, which is the whole trick, so the tail is part of
122
+ * the match rather than a miss.
123
+ */
124
+ const CONTROL_SEQUENCE = [
125
+ String.raw `(?:\u001B[\]P^_X]|[\u0090\u0098\u009D\u009E\u009F])[\s\S]*?(?:\u0007|\u001B\\|\u009C|$)`,
126
+ String.raw `(?:\u001B\[|\u009B)[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]`,
127
+ String.raw `\u001B[\u0020-\u002F]*[\u0030-\u007E]`,
128
+ String.raw `[\u001B\u0080-\u009F]`,
129
+ ].join('|');
130
+ /**
131
+ * Text substituted for a stripped control sequence. Visible on purpose: the
132
+ * lanes that strip are the ones an operator reads as evidence, and silently
133
+ * deleting the bytes would hide that a forgery was attempted.
134
+ */
135
+ export const CONTROL_SEQUENCE_PLACEHOLDER = '[REDACTED:dsh-dlp:control-sequence]';
136
+ /**
137
+ * Remove every terminal control sequence from one string.
138
+ *
139
+ * This is the `strip` half of {@link CONTROL_SEQUENCE_RULE}, applied on the
140
+ * lanes that must never carry forgeable bytes: an audit record and the strings
141
+ * an operator or an approval prompt reads back. Ordinary tool-result text takes
142
+ * the `report` half instead, because `git diff`, `rg` and `pytest` legitimately
143
+ * colourise their output.
144
+ * @param text - the string to clean.
145
+ * @returns the string with each control sequence replaced by a visible marker.
146
+ */
147
+ export function stripControlSequences(text) {
148
+ return text.replace(new RegExp(CONTROL_SEQUENCE, 'gu'), CONTROL_SEQUENCE_PLACEHOLDER);
149
+ }
150
+ /**
151
+ * Terminal control sequences in ordinary text.
152
+ *
153
+ * `report` rather than `strip`, deliberately: a tool result carrying SGR colour
154
+ * codes is the normal output of half the commands an agent runs, and replacing
155
+ * them would corrupt every one of those results. What the class buys on that
156
+ * lane is the count in the audit record.
157
+ */
158
+ const CONTROL_SEQUENCE_RULE = {
159
+ id: 'dsh-dlp/control-sequence',
160
+ version: 1,
161
+ severity: 'medium',
162
+ action: 'report',
163
+ pattern: new RegExp(CONTROL_SEQUENCE, 'gu'),
164
+ };
65
165
  /**
66
166
  * Character classes that hide text from the reader while the model still reads
67
- * it, verified against the Unicode character database.
167
+ * it, verified against the Unicode character database, plus the terminal
168
+ * control sequences that show the reader something other than what is there.
68
169
  *
69
170
  * Every class is `medium`. These are injection *indicators*, not credentials:
70
171
  * the guard floor denies at `high` and above, so an argument carrying one is
@@ -88,14 +189,22 @@ export const UNICODE_RULES = [
88
189
  unicodeRule('dsh-dlp/unicode-zero-width', 'report', String.raw `\u{200B}-\u{200D}\u{2060}\u{FEFF}`),
89
190
  // Bidi marks, unlike the overrides above, appear in real right-to-left text.
90
191
  unicodeRule('dsh-dlp/unicode-bidi-mark', 'report', String.raw `\u{061C}\u{200E}\u{200F}`),
91
- unicodeRule('dsh-dlp/unicode-variation-selector', 'report', String.raw `\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}`),
192
+ // An isolated selector is glyph selection and is left alone; a run of them
193
+ // is a byte string wearing the same code points.
194
+ unicodeRule('dsh-dlp/unicode-variation-selector', 'report', VARIATION_SELECTORS, `{1,${VARIATION_SELECTOR_RUN - 1}}`, true),
195
+ unicodeRule('dsh-dlp/unicode-variation-selector-run', 'strip', VARIATION_SELECTORS, `{${VARIATION_SELECTOR_RUN},}`),
196
+ CONTROL_SEQUENCE_RULE,
92
197
  ];
198
+ /** Classes whose matches are a run of one character class. */
199
+ const CHARACTER_RULES = UNICODE_RULES.filter(rule => rule.ranges !== undefined);
200
+ /** Classes whose matches have an ASCII body and are scanned over the whole input. */
201
+ const SEQUENCE_RULES = UNICODE_RULES.filter(rule => rule.ranges === undefined);
93
202
  /**
94
- * One run of any indicator class. The scan is a single pass over the input
95
- * with this pattern; the per-class patterns then run over the matched runs
96
- * only, which are a handful of characters each.
203
+ * One run of any character-class indicator. The scan is a single pass over the
204
+ * input with this pattern; the per-class patterns then run over the matched
205
+ * runs only, which are a handful of characters each.
97
206
  */
98
- const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('')}]+`, 'gu');
207
+ const UNICODE_RUN = new RegExp(`[${[...new Set(CHARACTER_RULES.map(rule => rule.ranges))].join('')}]+`, 'gu');
99
208
  /**
100
209
  * Find every invisible or direction-changing character in one string.
101
210
  *
@@ -107,20 +216,25 @@ const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('
107
216
  */
108
217
  export function scanUnicode(text) {
109
218
  const findings = [];
219
+ const found = (rule, start, length) => {
220
+ findings.push({
221
+ ruleId: rule.id,
222
+ ruleVersion: rule.version,
223
+ severity: rule.severity,
224
+ start,
225
+ end: start + length,
226
+ exact: true,
227
+ action: rule.action,
228
+ });
229
+ };
230
+ for (const rule of SEQUENCE_RULES) {
231
+ for (const match of text.matchAll(rule.pattern))
232
+ found(rule, match.index, match[0].length);
233
+ }
110
234
  for (const run of text.matchAll(UNICODE_RUN)) {
111
- for (const rule of UNICODE_RULES) {
112
- for (const match of run[0].matchAll(rule.pattern)) {
113
- const start = run.index + match.index;
114
- findings.push({
115
- ruleId: rule.id,
116
- ruleVersion: rule.version,
117
- severity: rule.severity,
118
- start,
119
- end: start + match[0].length,
120
- exact: true,
121
- action: rule.action,
122
- });
123
- }
235
+ for (const rule of CHARACTER_RULES) {
236
+ for (const match of run[0].matchAll(rule.pattern))
237
+ found(rule, run.index + match.index, match[0].length);
124
238
  }
125
239
  }
126
240
  findings.sort(byPosition);
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dsh-dlp` — data-loss prevention for DeepSeek Harness.
3
3
  *
4
- * Four registrations, in descending order of how much they can be trusted:
4
+ * Six registrations, in descending order of how much they can be trusted:
5
5
  *
6
6
  * 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
7
7
  * credential paths named in a path-typed argument and for secrets heading
@@ -9,6 +9,10 @@
9
9
  * has no allow arm.
10
10
  * 2. `tools/pre-execute` — the async breadth tier, which can await
11
11
  * `@secretlint/core`. Neutralizable by any listener registered ahead of it.
12
+ * 2b. `tools/pre-execute` — the `ask` tier for writes to behaviour-changing
13
+ * config paths. Deliberately here rather than on the floor: its rules have a
14
+ * real false-positive rate and the floor cannot ask. Neutralizable, and it
15
+ * abstains entirely when no approval service is mounted.
12
16
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
13
17
  * session event is appended, so the durable log records the redacted copy;
14
18
  * a result that cannot be cleaned is withheld rather than accepted.
@@ -36,6 +40,7 @@ import { SpanHasher } from "./redaction.js";
36
40
  import { safeEvaluateGuard } from "./guard.js";
37
41
  import { neutralizeImageStream } from "./images.js";
38
42
  import { ExecutionSnapshots, mutationReason } from "./mutation.js";
43
+ import { evaluateConfigWrite } from "./config-writes.js";
39
44
  import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
40
45
  import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
41
46
  import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
@@ -230,6 +235,59 @@ export function apply(ctx, config) {
230
235
  });
231
236
  return verdict.reason;
232
237
  }), 'dsh-dlp guard floor');
238
+ /**
239
+ * Report once that the `ask` tier has nowhere to ask.
240
+ *
241
+ * The registry resolves an `ask` through `ctx.get('approval')` and keeps the
242
+ * historical degrade to *deny* when no service is composed. This tier exists
243
+ * because its rules are too false-positive-prone for a deny, so under a
244
+ * deployment with no approval channel it abstains instead of becoming the
245
+ * silent hard deny it was designed not to be. Evaluated at decision time
246
+ * rather than at mount, because by then the harness is running and an absent
247
+ * service is conclusive rather than a load order.
248
+ */
249
+ let approvalSeamReported = false;
250
+ const discloseApprovalSeam = () => {
251
+ if (approvalSeamReported)
252
+ return;
253
+ approvalSeamReported = true;
254
+ notice(ctx, 'dsh-dlp: configWriteAsk is enabled, but no approval service is mounted, so an ask would degrade'
255
+ + ' to a denial. This tier abstains instead: a write to a behaviour-changing config path is allowed through'
256
+ + ' with no prompt. The guard floor is unaffected.');
257
+ };
258
+ if (policy.configWriteAsk) {
259
+ // Registered ahead of the breadth tier, so a call that is both a config
260
+ // write and carries a secret is denied rather than merely asked about:
261
+ // this listener sees whatever the rest of the waterfall settled on and
262
+ // only ever narrows `allow` into `ask`.
263
+ ctx.on('tools/pre-execute', async (exec, next) => {
264
+ const decision = await next();
265
+ if (decision.kind !== 'allow')
266
+ return decision;
267
+ const finding = evaluateConfigWrite(exec);
268
+ if (finding === undefined)
269
+ return decision;
270
+ // A call the floor will deny anyway is left to the floor. Any non-allow
271
+ // decision from this waterfall skips guards entirely, so asking here
272
+ // would replace an unconditional denial with a prompt a user can grant,
273
+ // and would file the decision as an ask rather than as a guard denial.
274
+ if (safeEvaluateGuard(exec, policy, hasher) !== undefined)
275
+ return decision;
276
+ if (ctx.get('approval') === undefined) {
277
+ discloseApprovalSeam();
278
+ return decision;
279
+ }
280
+ sink.write({
281
+ v: RECORD_VERSION,
282
+ time: new Date().toISOString(),
283
+ kind: 'pre-execute-ask',
284
+ decisionId: newDecisionId(),
285
+ ...identity(exec),
286
+ ruleId: finding.rule.id,
287
+ });
288
+ return { kind: 'ask', reason: finding.reason };
289
+ });
290
+ }
233
291
  if (policy.breadthTier) {
234
292
  ctx.on('tools/pre-execute', async (exec, next) => {
235
293
  const decision = await next();
package/lib/paths.js CHANGED
@@ -48,9 +48,61 @@ export const CREDENTIAL_PATH_RULES = [
48
48
  { id: 'dsh-dlp/path-pgpass', version: 1, pattern: /(^|\/)\.pgpass$/i },
49
49
  { id: 'dsh-dlp/path-mysql-config', version: 1, pattern: /(^|\/)\.my\.cnf$/i },
50
50
  { id: 'dsh-dlp/path-service-account', version: 1, pattern: /(^|\/)[^/]*service[._-]?account[^/]*\.json$/i },
51
+ // Coding-agent credential stores. IronWorm's 44 packages and SANDWORM_MODE
52
+ // name these verbatim; an `auth.json` under an agent's own directory is a
53
+ // token file whatever else the directory holds.
54
+ { id: 'dsh-dlp/path-agent-auth', version: 1, pattern: /(^|\/)\.?(codex|cursor|composer|windsurf|continue|aider|claude|gemini)\/auth\.json$/i },
55
+ // An MCP manifest carries each server's `env`, which is where its API keys
56
+ // are written.
57
+ { id: 'dsh-dlp/path-agent-mcp-config', version: 1, pattern: /(^|\/)\.(cursor|windsurf|continue|codex|claude|gemini)\/mcp\.json$/i },
58
+ // Cursor keeps session tokens in a SQLite state database rather than a
59
+ // credential file.
60
+ { id: 'dsh-dlp/path-editor-state-db', version: 1, pattern: /(^|\/)state\.vscdb(-journal|-wal|-shm)?$/i },
61
+ { id: 'dsh-dlp/path-macos-keychain', version: 1, pattern: /(^|\/)Library\/Keychains(\/|$)/i },
62
+ // A Terraform variables file is where provider credentials are written by
63
+ // convention, and state holds every provider's secrets in plaintext.
64
+ { id: 'dsh-dlp/path-terraform-vars', version: 1, pattern: /\.tfvars(\.json)?$/i },
65
+ { id: 'dsh-dlp/path-terraform-state', version: 1, pattern: /(^|\/)terraform\.tfstate(\.backup)?$/i },
51
66
  { id: 'dsh-dlp/path-keystore', version: 2, pattern: /\.(pem|p12|pfx|jks|keystore|key|asc|gpg)$/i },
52
67
  { id: 'dsh-dlp/path-credential-name', version: 1, pattern: new RegExp(String.raw `(^|\/)(?!.*\.(?:${CODE_EXTENSIONS})$)[^/]*(credentials?|secrets?|tokens?)([._-][^/]*)?$`, 'i') },
53
68
  ];
69
+ /**
70
+ * Escape one literal path so it can anchor a regular expression.
71
+ * @param literal - the path to quote.
72
+ * @returns the same text with every metacharacter escaped.
73
+ */
74
+ export function escapePathPattern(literal) {
75
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
76
+ }
77
+ /**
78
+ * Credential-path rules anchored at the user's home directory, resolved at
79
+ * mount because the directory is not known until then.
80
+ *
81
+ * A coding agent's *home* configuration decides how every future session in
82
+ * every repository behaves: the Miasma worm's `SessionStart` hooks went into
83
+ * exactly these files. Writing one is never ordinary repository work, so it is
84
+ * on the floor. Reading one is — a user asking the agent why its own
85
+ * configuration behaves a certain way is a normal request — so the rule is
86
+ * `writes-only` rather than `every-call`, unlike the `auth.json` and `mcp.json`
87
+ * stores in {@link CREDENTIAL_PATH_RULES}, which hold nothing but credentials.
88
+ *
89
+ * The *repository-local* copies of these same file names are a different
90
+ * question with a different answer: they are edited legitimately and often, so
91
+ * they sit in the neutralizable `ask` tier rather than on the floor.
92
+ * @param home - the user's home directory.
93
+ * @returns rules appended after the built-in table.
94
+ */
95
+ export function homeCredentialPathRules(home) {
96
+ // `~` survives normalization as a root-anchored marker, so a home-relative
97
+ // spelling reaches the same rule as the absolute one.
98
+ const anchor = `(?:${escapePathPattern(home)}|/~)`;
99
+ return [{
100
+ id: 'dsh-dlp/path-agent-home-settings',
101
+ version: 1,
102
+ enforcement: 'writes-only',
103
+ pattern: new RegExp(`^${anchor}/\\.(claude|gemini|codex|cursor|windsurf|continue)/settings[^/]*\\.json$`, 'i'),
104
+ }];
105
+ }
54
106
  /**
55
107
  * Normalize one candidate path for matching: Windows separators become
56
108
  * forward slashes, surrounding quotes come off, `~` expands to a
package/lib/policy.js CHANGED
@@ -15,12 +15,13 @@
15
15
  * @module dsh-dlp/policy
16
16
  */
17
17
  import { readFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
18
19
  import { resolve } from 'node:path';
19
20
  import { JSON_SCHEMA, load } from 'js-yaml';
20
21
  import z from '@deepseek-ai/schemastery';
21
- import { SYNC_RULES, severityRank } from "./detectors.js";
22
+ import { SYNC_RULES, severityRank, stripControlSequences } from "./detectors.js";
22
23
  import { resolveDshHome } from "./home.js";
23
- import { CREDENTIAL_PATH_RULES } from "./paths.js";
24
+ import { CREDENTIAL_PATH_RULES, escapePathPattern, homeCredentialPathRules } from "./paths.js";
24
25
  export const Config = z.object({
25
26
  auditLog: z.string().required(),
26
27
  redactionKeyFile: z.string().required(),
@@ -31,6 +32,7 @@ export const Config = z.object({
31
32
  telemetryRedaction: z.boolean().default(true),
32
33
  remoteImageNeutralization: z.boolean().default(true),
33
34
  redactTelemetryWorkspacePaths: z.boolean().default(true),
35
+ configWriteAsk: z.boolean().default(true),
34
36
  });
35
37
  /** Config toggles a repo-local policy may switch on, and never off. */
36
38
  const ENABLEABLE = [
@@ -39,6 +41,7 @@ const ENABLEABLE = [
39
41
  'telemetryRedaction',
40
42
  'remoteImageNeutralization',
41
43
  'redactTelemetryWorkspacePaths',
44
+ 'configWriteAsk',
42
45
  ];
43
46
  /** Keys a repo-local policy file may carry; anything else fails the load. */
44
47
  const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
@@ -119,6 +122,12 @@ function parseCredentialPathEntry(node, index) {
119
122
  if (typeof id !== 'string' || id.length === 0) {
120
123
  throw new PolicyError(`addCredentialPaths[${index}].id must be a non-empty string`);
121
124
  }
125
+ // A rule id is quoted verbatim in a model-facing denial and in every audit
126
+ // record the rule produces, and this file is attacker-controlled.
127
+ if (stripControlSequences(id) !== id) {
128
+ throw new PolicyError(`addCredentialPaths[${index}].id carries a terminal control sequence; a rule id is quoted in a denial`
129
+ + ' the user reads and in the audit record, so it may not rewrite what is on screen');
130
+ }
122
131
  if (typeof pattern !== 'string' || pattern.length === 0) {
123
132
  throw new PolicyError(`addCredentialPaths[${index}].pattern must be a non-empty string`);
124
133
  }
@@ -218,10 +227,6 @@ export function loadRepoPolicy(path) {
218
227
  return { kind: 'invalid', problem: String(error) };
219
228
  }
220
229
  }
221
- /** Escape one literal path so it can anchor a regular expression. */
222
- function escapePattern(literal) {
223
- return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
224
- }
225
230
  /**
226
231
  * Deny rules protecting this plugin's own state and the harness home.
227
232
  *
@@ -244,10 +249,10 @@ function escapePattern(literal) {
244
249
  * @returns rules appended after the built-in table.
245
250
  */
246
251
  function selfProtectionRules(config, dshHome) {
247
- const home = escapePattern(resolve(dshHome));
252
+ const home = escapePathPattern(resolve(dshHome));
248
253
  return [
249
- { id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.redactionKeyFile))}$`, 'i') },
250
- { id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.auditLog))}$`, 'i') },
254
+ { id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePathPattern(resolve(config.redactionKeyFile))}$`, 'i') },
255
+ { id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${escapePathPattern(resolve(config.auditLog))}$`, 'i') },
251
256
  { id: 'dsh-dlp/path-dsh-sessions', version: 1, pattern: new RegExp(`^${home}/sessions(/|$)`, 'i') },
252
257
  { id: 'dsh-dlp/path-dsh-home', version: 2, enforcement: 'writes-only', pattern: new RegExp(`^${home}(/|$)`, 'i') },
253
258
  ];
@@ -263,6 +268,7 @@ export function resolvePolicy(config, repo) {
263
268
  return {
264
269
  credentialPathRules: [
265
270
  ...CREDENTIAL_PATH_RULES,
271
+ ...homeCredentialPathRules(resolve(homedir())),
266
272
  ...selfProtectionRules(config, resolveDshHome()),
267
273
  ...repo?.addCredentialPaths ?? [],
268
274
  ],
@@ -277,5 +283,6 @@ export function resolvePolicy(config, repo) {
277
283
  telemetryRedaction: enabled('telemetryRedaction'),
278
284
  remoteImageNeutralization: enabled('remoteImageNeutralization'),
279
285
  redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
286
+ configWriteAsk: enabled('configWriteAsk'),
280
287
  };
281
288
  }
package/lib/sink.js CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { appendFileSync } from 'node:fs';
18
18
  import { randomUUID } from 'node:crypto';
19
+ import { stripControlSequences } from "./detectors.js";
19
20
  /**
20
21
  * Mint a decision id.
21
22
  * @returns an id unique to one guard verdict or redaction pass.
@@ -25,6 +26,29 @@ export function newDecisionId() {
25
26
  }
26
27
  /** Payload version carried inside every record this plugin writes. */
27
28
  export const RECORD_VERSION = 1;
29
+ /**
30
+ * One record with every string cleaned of terminal control sequences.
31
+ *
32
+ * A record carries strings this plugin did not author — a tool's registered
33
+ * name, a call id, a rule id from the repo-local policy tier — and a reader
34
+ * gets them back unescaped: `JSON.stringify` writes `\u001b` to the file, but
35
+ * `dsh-dlp report`, `jq -r` and any log viewer parse that back into a live
36
+ * escape. A tool named with a CSI sequence could then overwrite the line
37
+ * describing it, which is the forged-audit-record half of CVE-2026-35651.
38
+ * Cleaning here means every consumer of the file gets the cleaned form.
39
+ * @param value - any part of a record.
40
+ * @returns the same structure with control sequences replaced.
41
+ */
42
+ function cleaned(value) {
43
+ if (typeof value === 'string')
44
+ return stripControlSequences(value);
45
+ if (Array.isArray(value))
46
+ return value.map(cleaned);
47
+ if (typeof value === 'object' && value !== null) {
48
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [stripControlSequences(key), cleaned(item)]));
49
+ }
50
+ return value;
51
+ }
28
52
  /** Append-only JSONL sink for this plugin's decisions. */
29
53
  export class AuditSink {
30
54
  #path;
@@ -49,7 +73,7 @@ export class AuditSink {
49
73
  */
50
74
  write(record) {
51
75
  try {
52
- appendFileSync(this.#path, `${JSON.stringify(record)}\n`);
76
+ appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`);
53
77
  }
54
78
  catch (error) {
55
79
  this.#onFailure(error);
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The `ask` tier: files whose contents decide how the agent, the editor or CI
3
+ * behaves next time, and the one config *key* whose value redirects a
4
+ * credential.
5
+ *
6
+ * This tier is deliberately **not** on the guard floor, and that is the whole
7
+ * design. `ctx.tools.guard()` has no `ask` arm and cannot be overridden, so a
8
+ * rule that lands there must be one a developer never legitimately trips. A
9
+ * developer asks the agent to edit `CLAUDE.md`, add a `.github/workflows`
10
+ * job or extend `.vscode/settings.json` constantly. Putting those on an
11
+ * unoverridable floor produces a plugin that gets uninstalled, which removes
12
+ * the floor as well.
13
+ *
14
+ * The cost is stated rather than hidden: `tools/pre-execute` is neutralizable.
15
+ * A listener registered ahead of this one can return without calling `next()`
16
+ * and this tier never runs. Only the guard floor is order-independent.
17
+ * @module dsh-dlp/config-writes
18
+ */
19
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
20
+ /** What a behaviour-config rule is matched against. */
21
+ export type ConfigMatch =
22
+ /** A path-typed argument: the file the call would create or change. */
23
+ 'path'
24
+ /** A content-typed argument: the bytes the call would write. */
25
+ | 'content';
26
+ /** One file, or one written value, that changes what happens next time. */
27
+ export interface ConfigWriteRule {
28
+ readonly id: string;
29
+ readonly version: number;
30
+ readonly match: ConfigMatch;
31
+ readonly pattern: RegExp;
32
+ /** What the file or value does, quoted in the prompt the user answers. */
33
+ readonly effect: string;
34
+ }
35
+ /**
36
+ * Paths whose contents change future behaviour, and the config key whose value
37
+ * redirects credentials.
38
+ *
39
+ * Every path here was used in the wild. The Miasma worm wrote `SessionStart`
40
+ * hooks into `.claude/settings.json` and `.gemini/settings.json`, an
41
+ * always-apply `.cursor/rules/setup.mdc`, a `folderOpen` task into
42
+ * `.vscode/tasks.json`, and a hijacked `npm test` into `Azure/durabletask`;
43
+ * GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples
44
+ * over it, 39 of them inside 38 seconds. See also CVE-2025-53773,
45
+ * CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
46
+ * CVE-2025-59041.
47
+ *
48
+ * The rules match by name, never by what is on disk, so a file the call is
49
+ * about to *create* is matched exactly like one it would change:
50
+ * CVE-2026-25725 worked precisely because the path did not exist yet and was
51
+ * therefore writable without any prompt.
52
+ */
53
+ export declare const CONFIG_WRITE_RULES: readonly ConfigWriteRule[];
54
+ /**
55
+ * Argument keys whose values are the bytes a call would write.
56
+ *
57
+ * Deliberately separate from the floor's path-typed keys: the floor must never
58
+ * run its path table over file content, and this tier must run its one content
59
+ * rule over nothing else.
60
+ */
61
+ export declare const CONTENT_ARGUMENT_KEYS: ReadonlySet<string>;
62
+ /**
63
+ * The strings one call would write.
64
+ * @param args - the pending call's parsed arguments.
65
+ * @returns every string under a content-typed key, at any depth.
66
+ */
67
+ export declare function contentArguments(args: unknown): string[];
68
+ /** A call this tier wants a human to confirm. */
69
+ export interface ConfigWriteFinding {
70
+ readonly rule: ConfigWriteRule;
71
+ /** Model- and user-facing text; names the rule and what the file does, never the path. */
72
+ readonly reason: string;
73
+ }
74
+ /**
75
+ * Decide whether one call should be confirmed by a human.
76
+ *
77
+ * Only calls that can change something are examined: a tool
78
+ * {@link isReadOnlyTool} classifies as query-only cannot write a hook. Shell
79
+ * command lines are deliberately **not** tokenised here, unlike in the floor:
80
+ * a command line cannot be told apart from a read of the same path, and
81
+ * prompting on `cat .github/workflows/ci.yml` is exactly the false positive
82
+ * that gets a tier switched off. A shell redirection into one of these files
83
+ * is therefore not covered, which README.md says beside the feature.
84
+ * @param exec - the pending call.
85
+ * @param rules - the rule table; defaults to {@link CONFIG_WRITE_RULES}.
86
+ * @returns the finding, or `undefined` to leave the call alone.
87
+ */
88
+ export declare function evaluateConfigWrite(exec: Pick<ToolExecution, 'name' | 'arguments'>, rules?: readonly ConfigWriteRule[]): ConfigWriteFinding | undefined;
89
+ //# sourceMappingURL=config-writes.d.ts.map
@@ -64,6 +64,11 @@ export interface SyncRule {
64
64
  * delimiters make a match structurally unambiguous, plus PEM blocks and
65
65
  * credential-bearing URLs. Anything requiring entropy heuristics is left to
66
66
  * tier 2, where a false positive costs a redaction rather than a denial.
67
+ *
68
+ * Prefix-anchored is the whole membership criterion, and the reason this table
69
+ * keeps growing rather than deferring to tier 2: the `session-telemetry/record`
70
+ * waterfall is synchronous and cannot reach tier 2 at all, so a format missing
71
+ * here is exported in the clear when telemetry is on.
67
72
  */
68
73
  export declare const SYNC_RULES: readonly SyncRule[];
69
74
  /**
@@ -72,8 +77,14 @@ export declare const SYNC_RULES: readonly SyncRule[];
72
77
  *
73
78
  * `strip` classes have no legitimate use in tool output, so they are replaced
74
79
  * like any other detection. `report` classes do: `U+200D` joins an emoji
75
- * sequence and a variation selector chooses a glyph, so replacing them would
76
- * corrupt ordinary text. They are counted and never rewritten.
80
+ * sequence, a variation selector chooses a glyph, and a CSI sequence colours
81
+ * the output of `git diff`, so replacing them would corrupt ordinary text.
82
+ * They are counted and never rewritten.
83
+ *
84
+ * The split is per class and per lane both: {@link stripControlSequences}
85
+ * applies the `strip` treatment to a `report` class on the audit and
86
+ * approval-facing lanes, where colour buys nothing and a forged record costs
87
+ * everything.
77
88
  */
78
89
  export type UnicodeAction = 'strip' | 'report';
79
90
  /** One class of invisible or direction-changing characters. */
@@ -84,12 +95,37 @@ export interface UnicodeRule {
84
95
  readonly action: UnicodeAction;
85
96
  /** Global, unicode-flagged, matching one run of this class. */
86
97
  readonly pattern: RegExp;
87
- /** The class's ranges as a character-class body, for the combined run pattern. */
88
- readonly ranges: string;
98
+ /**
99
+ * The class's ranges as a character-class body, for the combined run pattern.
100
+ *
101
+ * Absent for a class whose matches are not a run of one character class —
102
+ * a terminal control sequence has an ASCII body — which is scanned over the
103
+ * whole input instead of within a combined run.
104
+ */
105
+ readonly ranges?: string;
89
106
  }
107
+ /**
108
+ * Text substituted for a stripped control sequence. Visible on purpose: the
109
+ * lanes that strip are the ones an operator reads as evidence, and silently
110
+ * deleting the bytes would hide that a forgery was attempted.
111
+ */
112
+ export declare const CONTROL_SEQUENCE_PLACEHOLDER = "[REDACTED:dsh-dlp:control-sequence]";
113
+ /**
114
+ * Remove every terminal control sequence from one string.
115
+ *
116
+ * This is the `strip` half of {@link CONTROL_SEQUENCE_RULE}, applied on the
117
+ * lanes that must never carry forgeable bytes: an audit record and the strings
118
+ * an operator or an approval prompt reads back. Ordinary tool-result text takes
119
+ * the `report` half instead, because `git diff`, `rg` and `pytest` legitimately
120
+ * colourise their output.
121
+ * @param text - the string to clean.
122
+ * @returns the string with each control sequence replaced by a visible marker.
123
+ */
124
+ export declare function stripControlSequences(text: string): string;
90
125
  /**
91
126
  * Character classes that hide text from the reader while the model still reads
92
- * it, verified against the Unicode character database.
127
+ * it, verified against the Unicode character database, plus the terminal
128
+ * control sequences that show the reader something other than what is there.
93
129
  *
94
130
  * Every class is `medium`. These are injection *indicators*, not credentials:
95
131
  * the guard floor denies at `high` and above, so an argument carrying one is
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dsh-dlp` — data-loss prevention for DeepSeek Harness.
3
3
  *
4
- * Four registrations, in descending order of how much they can be trusted:
4
+ * Six registrations, in descending order of how much they can be trusted:
5
5
  *
6
6
  * 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
7
7
  * credential paths named in a path-typed argument and for secrets heading
@@ -9,6 +9,10 @@
9
9
  * has no allow arm.
10
10
  * 2. `tools/pre-execute` — the async breadth tier, which can await
11
11
  * `@secretlint/core`. Neutralizable by any listener registered ahead of it.
12
+ * 2b. `tools/pre-execute` — the `ask` tier for writes to behaviour-changing
13
+ * config paths. Deliberately here rather than on the floor: its rules have a
14
+ * real false-positive rate and the floor cannot ask. Neutralizable, and it
15
+ * abstains entirely when no approval service is mounted.
12
16
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
13
17
  * session event is appended, so the durable log records the redacted copy;
14
18
  * a result that cannot be cleaned is withheld rather than accepted.
@@ -40,6 +40,31 @@ export interface CredentialPathRule {
40
40
  * authenticates with is agent-readable. That is the gap this table closes.
41
41
  */
42
42
  export declare const CREDENTIAL_PATH_RULES: readonly CredentialPathRule[];
43
+ /**
44
+ * Escape one literal path so it can anchor a regular expression.
45
+ * @param literal - the path to quote.
46
+ * @returns the same text with every metacharacter escaped.
47
+ */
48
+ export declare function escapePathPattern(literal: string): string;
49
+ /**
50
+ * Credential-path rules anchored at the user's home directory, resolved at
51
+ * mount because the directory is not known until then.
52
+ *
53
+ * A coding agent's *home* configuration decides how every future session in
54
+ * every repository behaves: the Miasma worm's `SessionStart` hooks went into
55
+ * exactly these files. Writing one is never ordinary repository work, so it is
56
+ * on the floor. Reading one is — a user asking the agent why its own
57
+ * configuration behaves a certain way is a normal request — so the rule is
58
+ * `writes-only` rather than `every-call`, unlike the `auth.json` and `mcp.json`
59
+ * stores in {@link CREDENTIAL_PATH_RULES}, which hold nothing but credentials.
60
+ *
61
+ * The *repository-local* copies of these same file names are a different
62
+ * question with a different answer: they are edited legitimately and often, so
63
+ * they sit in the neutralizable `ask` tier rather than on the floor.
64
+ * @param home - the user's home directory.
65
+ * @returns rules appended after the built-in table.
66
+ */
67
+ export declare function homeCredentialPathRules(home: string): readonly CredentialPathRule[];
43
68
  /**
44
69
  * Normalize one candidate path for matching: Windows separators become
45
70
  * forward slashes, surrounding quotes come off, `~` expands to a
@@ -37,10 +37,16 @@ export interface Config {
37
37
  remoteImageNeutralization: boolean;
38
38
  /** Whether telemetry's `session.cwd` attribute is replaced with a keyed hash. */
39
39
  redactTelemetryWorkspacePaths: boolean;
40
+ /**
41
+ * Whether a write to a behaviour-changing config path asks the user first.
42
+ * Needs an approval service; without one the tier abstains rather than
43
+ * letting an `ask` degrade into a denial.
44
+ */
45
+ configWriteAsk: boolean;
40
46
  }
41
47
  export declare const Config: z<Config>;
42
48
  /** Config toggles a repo-local policy may switch on, and never off. */
43
- declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths"];
49
+ declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk"];
44
50
  /** One toggle name a repo-local policy may name in `enable`. */
45
51
  export type EnableableToggle = typeof ENABLEABLE[number];
46
52
  /** Payload version this package writes and accepts for repo-local policy files. */
@@ -63,6 +69,7 @@ export interface ResolvedPolicy {
63
69
  readonly telemetryRedaction: boolean;
64
70
  readonly remoteImageNeutralization: boolean;
65
71
  readonly redactTelemetryWorkspacePaths: boolean;
72
+ readonly configWriteAsk: boolean;
66
73
  }
67
74
  /** Thrown when a policy file is malformed or attempts to loosen the policy. */
68
75
  export declare class PolicyError extends Error {
@@ -28,7 +28,7 @@ export declare function newDecisionId(): DecisionId;
28
28
  /** Payload version carried inside every record this plugin writes. */
29
29
  export declare const RECORD_VERSION = 1;
30
30
  /** What produced one audit record. */
31
- export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
31
+ export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
32
32
  /** One durable record. Never carries matched secret text. */
33
33
  export interface AuditRecord {
34
34
  readonly v: number;
@@ -60,6 +60,12 @@ export interface AuditRecord {
60
60
  * instruction is exactly the content this file must not repeat.
61
61
  */
62
62
  readonly unicode?: Readonly<Record<string, number>>;
63
+ /**
64
+ * The single rule behind a decision that has no matched region to describe,
65
+ * which is every `pre-execute-ask`: the finding is that a path names a
66
+ * behaviour-changing file, not that any part of it matched a secret.
67
+ */
68
+ readonly ruleId?: string;
63
69
  /** Telemetry record channel, for `telemetry-redaction`. */
64
70
  readonly channel?: string;
65
71
  /** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.3.0",
3
+ "version": "0.4.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>",