dsh-dlp 0.2.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 +282 -12
- package/SECURITY.md +14 -6
- package/cordis.patch.yml +2 -0
- package/lib/cli.js +8 -4
- package/lib/config-writes.js +215 -0
- package/lib/detectors.js +136 -22
- package/lib/images.js +183 -0
- package/lib/index.js +140 -2
- package/lib/mutation.js +102 -0
- package/lib/paths.js +52 -0
- package/lib/policy.js +25 -10
- package/lib/sink.js +25 -1
- package/lib/telemetry.js +29 -0
- package/lib/types/config-writes.d.ts +89 -0
- package/lib/types/detectors.d.ts +41 -5
- package/lib/types/images.d.ts +81 -0
- package/lib/types/index.d.ts +14 -1
- package/lib/types/mutation.d.ts +83 -0
- package/lib/types/paths.d.ts +25 -0
- package/lib/types/policy.d.ts +11 -1
- package/lib/types/sink.d.ts +18 -1
- package/lib/types/telemetry.d.ts +16 -1
- package/package.json +1 -1
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
|
|
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
|
|
@@ -15,7 +15,16 @@ It does five things:
|
|
|
15
15
|
4. **Strips the invisible characters that carry hidden instructions** out of tool results —
|
|
16
16
|
the Tags block and bidi overrides — and counts the classes it will not touch because they
|
|
17
17
|
also appear in legitimate text.
|
|
18
|
-
5. **
|
|
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.
|
|
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,
|
|
19
28
|
offsets, and a keyed hash. Never the secret, and never the path or command that matched.
|
|
20
29
|
`dsh-dlp report` reads that sink back.
|
|
21
30
|
|
|
@@ -34,6 +43,12 @@ egress firewalling. Use this alongside them, not instead of them.
|
|
|
34
43
|
|
|
35
44
|
More limits worth stating up front:
|
|
36
45
|
|
|
46
|
+
- **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.
|
|
37
52
|
- **The shell-command arm is advisory pattern-matching.** A `bash` command line is split on
|
|
38
53
|
shell-ish separators and each token is tested as a path. That catches an unobfuscated
|
|
39
54
|
`cat ~/.ssh/id_rsa`. It catches nothing that tries: `cat ~/.netr?` (one glob character),
|
|
@@ -50,7 +65,8 @@ More limits worth stating up front:
|
|
|
50
65
|
- **Already-logged history cannot be rewritten; a not-yet-logged inbound message can.** At
|
|
51
66
|
`llm/stream` the options are deep-frozen and `next()` takes no arguments, so a request the
|
|
52
67
|
agent has assembled goes out as it stands and a secret already in the conversation reaches
|
|
53
|
-
the provider.
|
|
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
|
|
54
70
|
returning `{ kind: 'enter'; messages }`, and the only production append of `user/message`
|
|
55
71
|
happens *after* it, so a message arriving from outside can still be rewritten before it is
|
|
56
72
|
logged or presented. This release does not do that; it is recorded here because the earlier
|
|
@@ -71,8 +87,143 @@ More limits worth stating up front:
|
|
|
71
87
|
is 100% for anything up to 22 characters, which is most of the credential formats worth
|
|
72
88
|
catching. A detector that fires on the long ones the prefix rules already catch and misses
|
|
73
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.
|
|
74
103
|
|
|
75
|
-
|
|
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
|
+
`` 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
|
+
 -> 
|
|
127
|
+
```
|
|
128
|
+
|
|
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.
|
|
76
227
|
|
|
77
228
|
---
|
|
78
229
|
|
|
@@ -102,7 +253,7 @@ checkout, build first and add the tarball:
|
|
|
102
253
|
```sh
|
|
103
254
|
git clone https://github.com/CharlotteN7/dsh-dlp && cd dsh-dlp
|
|
104
255
|
pnpm install && pnpm run build && pnpm pack
|
|
105
|
-
dsh plugin --profile <name> add ./dsh-dlp-0.
|
|
256
|
+
dsh plugin --profile <name> add ./dsh-dlp-0.3.0.tgz
|
|
106
257
|
```
|
|
107
258
|
|
|
108
259
|
## Configure
|
|
@@ -118,7 +269,9 @@ dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
|
|
|
118
269
|
breadthTier: true
|
|
119
270
|
resultRedaction: true
|
|
120
271
|
telemetryRedaction: true
|
|
272
|
+
remoteImageNeutralization: true
|
|
121
273
|
redactTelemetryWorkspacePaths: true
|
|
274
|
+
configWriteAsk: true
|
|
122
275
|
```
|
|
123
276
|
|
|
124
277
|
`redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out
|
|
@@ -148,7 +301,7 @@ addCredentialPaths:
|
|
|
148
301
|
addEgressTools: [acme_publish]
|
|
149
302
|
raiseSeverity:
|
|
150
303
|
dsh-dlp/secret-assignment: high
|
|
151
|
-
enable: [telemetryRedaction]
|
|
304
|
+
enable: [telemetryRedaction, configWriteAsk]
|
|
152
305
|
```
|
|
153
306
|
|
|
154
307
|
Any other key, and any downgrade, makes the **whole file invalid**: it is reported on
|
|
@@ -181,6 +334,22 @@ directories (but not `.env.example`), anything under `.ssh/`,
|
|
|
181
334
|
documentation extensions are excluded from that last rule, so `src/auth/token.ts` stays
|
|
182
335
|
readable.
|
|
183
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
|
+
|
|
184
353
|
Also denied for every tool: this plugin's own `redactionKeyFile` and `auditLog`.
|
|
185
354
|
|
|
186
355
|
**`$DSH_HOME` is split by direction.** Every *write* under the harness home is denied, for
|
|
@@ -235,6 +404,69 @@ need, or use a path that is not a credential store.
|
|
|
235
404
|
|
|
236
405
|
---
|
|
237
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
|
+
|
|
238
470
|
## What gets redacted
|
|
239
471
|
|
|
240
472
|
A redacted region becomes:
|
|
@@ -283,10 +515,16 @@ the redacted copy.
|
|
|
283
515
|
Two tiers:
|
|
284
516
|
|
|
285
517
|
- **Tier 1**, synchronous and owned by this package: prefix-anchored token formats (AWS,
|
|
286
|
-
GitHub, Slack, Stripe, OpenAI, Anthropic, Google
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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.
|
|
290
528
|
- **Tier 2**, [`@secretlint/core`](https://github.com/secretlint/secretlint) with the
|
|
291
529
|
recommended preset — 28 maintained rules, in-process, no subprocess. Used at
|
|
292
530
|
`tools/pre-execute` and `tools/post-execute`, the two seams that can await. **The telemetry
|
|
@@ -309,7 +547,9 @@ session titles — and never on the tool-result path.
|
|
|
309
547
|
| Bidi overrides and isolates | `U+202A–U+202E`, `U+2066–U+2069` | replaced |
|
|
310
548
|
| Zero-width | `U+200B–U+200D`, `U+2060`, `U+FEFF` | counted only |
|
|
311
549
|
| Bidi marks | `U+061C`, `U+200E–U+200F` | counted only |
|
|
312
|
-
| 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** |
|
|
313
553
|
|
|
314
554
|
The first two have no legitimate use in tool output — the Tags block is a full invisible ASCII
|
|
315
555
|
alphabet, which is what makes it the standard carrier for a hidden instruction. The last three
|
|
@@ -322,6 +562,30 @@ character is never turned into a denial. A replaced run becomes an ordinary plac
|
|
|
322
562
|
unlike a secret, is replaced exactly: an invisible character is not widened to its surrounding
|
|
323
563
|
delimiters, so the visible word it hid inside survives.
|
|
324
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
|
+
|
|
325
589
|
**A homoglyph defeats all of this**, and every other rule in this plugin. A Cyrillic `а` in
|
|
326
590
|
`аdmin` is a normal, visible, legitimately-encoded character; detecting it means UTS #39
|
|
327
591
|
confusable tables, which is a data set and a different cost class. This plugin does not attempt
|
|
@@ -383,7 +647,13 @@ its own identity.
|
|
|
383
647
|
}
|
|
384
648
|
```
|
|
385
649
|
|
|
386
|
-
`kind` is one of `guard-deny`, `pre-execute-deny`, `
|
|
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.
|
|
387
657
|
A `result-redaction` record may also carry `unicode`, a count of invisible-character runs per
|
|
388
658
|
class — counts only, because a hidden instruction is exactly the content this file must not
|
|
389
659
|
repeat. A record is written whenever there is something to say, including a result that was
|
package/SECURITY.md
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
| Version | Supported |
|
|
6
6
|
|---|---|
|
|
7
|
-
| 0.
|
|
8
|
-
| < 0.
|
|
7
|
+
| 0.3.x | yes |
|
|
8
|
+
| < 0.3 | no |
|
|
9
9
|
|
|
10
|
-
Only the latest published `0.
|
|
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
|
|
|
@@ -29,7 +30,7 @@ otherwise.
|
|
|
29
30
|
|
|
30
31
|
This plugin is **not a containment boundary**. It runs in-process at the agent's own uid, so
|
|
31
32
|
anything the agent can execute can read the same files the guard denies. The following are
|
|
32
|
-
documented limits, not vulnerabilities — they are described in README.md
|
|
33
|
+
documented limits, not vulnerabilities — they are described in README.md:
|
|
33
34
|
|
|
34
35
|
- shell-command obfuscation defeating the `bash` path arm (globbing, quoting, substitution, a
|
|
35
36
|
different binary);
|
|
@@ -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
package/lib/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { defaultAuditLog } from "./home.js";
|
|
18
18
|
/** Decision kinds that stopped a call, as opposed to rewriting its result. */
|
|
19
|
-
const DENYING_KINDS = new Set(['guard-deny', 'pre-execute-deny']);
|
|
19
|
+
const DENYING_KINDS = new Set(['guard-deny', 'pre-execute-deny', 'execution-mutation']);
|
|
20
20
|
/** How many decisions the report lists individually. */
|
|
21
21
|
const RECENT_LIMIT = 10;
|
|
22
22
|
/** Read one string field, or `undefined` when the line does not carry it. */
|
|
@@ -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
|
-
/**
|
|
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) {
|