dsh-dlp 0.1.0 → 0.3.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 +297 -21
- package/SECURITY.md +1 -1
- package/cordis.patch.yml +1 -0
- package/lib/cli.js +307 -0
- package/lib/detectors.js +87 -0
- package/lib/guard.js +9 -6
- package/lib/home.js +37 -0
- package/lib/images.js +183 -0
- package/lib/index.js +112 -7
- package/lib/mutation.js +102 -0
- package/lib/paths.js +38 -0
- package/lib/policy.js +24 -17
- package/lib/redaction.js +9 -1
- package/lib/results.js +25 -9
- package/lib/schema.js +99 -0
- package/lib/telemetry.js +29 -0
- package/lib/types/cli.d.ts +107 -0
- package/lib/types/detectors.d.ts +66 -0
- package/lib/types/guard.d.ts +6 -4
- package/lib/types/home.d.ts +31 -0
- package/lib/types/images.d.ts +81 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/mutation.d.ts +83 -0
- package/lib/types/paths.d.ts +38 -0
- package/lib/types/policy.d.ts +4 -10
- package/lib/types/results.d.ts +15 -6
- package/lib/types/schema.d.ts +41 -0
- package/lib/types/sink.d.ts +18 -1
- package/lib/types/telemetry.d.ts +16 -1
- package/package.json +4 -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 six 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
|
|
@@ -12,8 +12,16 @@ It does four things:
|
|
|
12
12
|
log records them, and withholds a result it cannot clean.
|
|
13
13
|
3. **Redacts secrets out of exported telemetry**, patching a hole where `DSH_TELEMETRY_MODE=FULL`
|
|
14
14
|
ships message text, tool arguments, tool results and workspace paths in the clear.
|
|
15
|
-
4. **
|
|
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.
|
|
22
|
+
6. **Writes an audit record for every decision** to its own sink — rule id, rule version,
|
|
16
23
|
offsets, and a keyed hash. Never the secret, and never the path or command that matched.
|
|
24
|
+
`dsh-dlp report` reads that sink back.
|
|
17
25
|
|
|
18
26
|
---
|
|
19
27
|
|
|
@@ -30,6 +38,12 @@ egress firewalling. Use this alongside them, not instead of them.
|
|
|
30
38
|
|
|
31
39
|
More limits worth stating up front:
|
|
32
40
|
|
|
41
|
+
- **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
|
|
42
|
+
registered ahead of ours: a `tools/pre-execute` listener that returns without calling `next()`
|
|
43
|
+
disables the breadth tier, and a `tools/post-execute` listener ahead of ours can replace a
|
|
44
|
+
result after it was redacted. `ctx.tools.guard()` is order-independent only because it has no
|
|
45
|
+
allow arm. A `tools/pre-execute` deny also skips guards entirely, so the audit sink cannot
|
|
46
|
+
claim to have seen every call.
|
|
33
47
|
- **The shell-command arm is advisory pattern-matching.** A `bash` command line is split on
|
|
34
48
|
shell-ish separators and each token is tested as a path. That catches an unobfuscated
|
|
35
49
|
`cat ~/.ssh/id_rsa`. It catches nothing that tries: `cat ~/.netr?` (one glob character),
|
|
@@ -43,14 +57,168 @@ More limits worth stating up front:
|
|
|
43
57
|
the session log and already presented to the model, so rewriting them would desynchronise
|
|
44
58
|
the log from what actually ran. Argument-level DLP here is *denial with a reason the model
|
|
45
59
|
can act on*.
|
|
46
|
-
- **
|
|
47
|
-
|
|
60
|
+
- **Already-logged history cannot be rewritten; a not-yet-logged inbound message can.** At
|
|
61
|
+
`llm/stream` the options are deep-frozen and `next()` takes no arguments, so a request the
|
|
62
|
+
agent has assembled goes out as it stands and a secret already in the conversation reaches
|
|
63
|
+
the provider. (The same waterfall's *response* side is writable, and that is where remote
|
|
64
|
+
image destinations are neutralised — see below.) That is not the whole rule, though: `agent/pre-step` is an async waterfall
|
|
65
|
+
returning `{ kind: 'enter'; messages }`, and the only production append of `user/message`
|
|
66
|
+
happens *after* it, so a message arriving from outside can still be rewritten before it is
|
|
67
|
+
logged or presented. This release does not do that; it is recorded here because the earlier
|
|
68
|
+
flat claim that outbound redaction is impossible was too strong.
|
|
69
|
+
- **A redacted value is not restored when the agent runs a command.** `ctx.shellEnv` rebuilds a
|
|
70
|
+
trusted `DSH_*` namespace for every model shell call, which is a way to hand `bash` and
|
|
71
|
+
`pwsh` — and only those two — the real value behind a placeholder without the model ever
|
|
72
|
+
seeing it. Planned work, not implemented here.
|
|
48
73
|
- **Detection is pattern-based.** A password, an internal token format, or a customer record
|
|
49
74
|
has no recognisable structure and is not detected. Neither is any encoded form: base64,
|
|
50
75
|
hex, URL-escaping and reversal all pass both tiers, as does a secret split across two
|
|
51
|
-
content blocks.
|
|
76
|
+
content blocks. **A homoglyph defeats every rule in this package**, including the
|
|
77
|
+
invisible-character ones.
|
|
78
|
+
- **There is no entropy rule, and that was measured rather than assumed.** Shannon entropy is
|
|
79
|
+
bounded by log₂L for a string of length L, so a 20-character token cannot score above 4.32
|
|
80
|
+
bits per character however random it is. At the threshold where ordinary tool output —
|
|
81
|
+
hashes, minified bundles, base64 blobs, UUIDs — produces no false positives, the miss rate
|
|
82
|
+
is 100% for anything up to 22 characters, which is most of the credential formats worth
|
|
83
|
+
catching. A detector that fires on the long ones the prefix rules already catch and misses
|
|
84
|
+
the rest is not worth the false positives it costs.
|
|
85
|
+
- **A secret containing a delimiter can still be split across two redactions.** Every reported
|
|
86
|
+
span grows outward to the nearest delimiter, which over-redacts in the safe direction, but a
|
|
87
|
+
secret whose own text contains one of those delimiters is covered by two placeholders with the
|
|
88
|
+
delimiter left between them.
|
|
89
|
+
- **`additionalContexts` are not scanned.** They are model-visible `UserMessage` payloads and
|
|
90
|
+
this release does not redact them.
|
|
91
|
+
- **Local writes are out of scope.** A `write` or `edit` into a synced directory moves data off
|
|
92
|
+
the machine without going through an egress-capable tool.
|
|
93
|
+
- **Telemetry redaction covers a mounted backend's records only.** A second exporter mounted
|
|
94
|
+
outside the `session-telemetry/record` waterfall is not covered.
|
|
95
|
+
- **`$DSH_HOME` is readable by a read-only tool.** Profile manifests and the installed plugin
|
|
96
|
+
tree are ordinary work to read, so which plugins a profile loads is model-visible. Only writes
|
|
97
|
+
are denied wholesale there, plus reads of the credential material inside it.
|
|
52
98
|
|
|
53
|
-
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Mitigations for defects in the harness itself
|
|
102
|
+
|
|
103
|
+
Three of this plugin's registrations work around defects in DeepSeek Harness, not in a
|
|
104
|
+
deployment's configuration. **None of them closes its channel**, an upstream fix is better in
|
|
105
|
+
all three cases, and each is written up in `../disclosures/findings/`. They are here because we
|
|
106
|
+
build on these seams today and wanted the accident case narrowed while the upstream question is
|
|
107
|
+
open.
|
|
108
|
+
|
|
109
|
+
### Remote markdown images in assistant output (finding 001)
|
|
110
|
+
|
|
111
|
+
The web UI renders any absolute `http(s)` markdown image a model emits as a real `<img src>`,
|
|
112
|
+
and the harness sets no Content-Security-Policy. An injected agent emitting
|
|
113
|
+
`` makes **your browser** issue that
|
|
114
|
+
request; the harness process never sees it, so no guard, no DLP pass and no audit surface here
|
|
115
|
+
can observe it.
|
|
116
|
+
|
|
117
|
+
This plugin wraps the `llm/stream` waterfall and replaces the destination of every inline
|
|
118
|
+
markdown image whose target is an absolute `http:`/`https:` URL, keeping the alt text:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
 -> 
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The placeholder is deliberately not a URL, so the renderer takes its own "not an absolute
|
|
125
|
+
destination" arm and shows the alt text instead of fetching anything. Rewriting happens before
|
|
126
|
+
the text becomes an `assistant/chunk` or `assistant/message` event, so the session log and the
|
|
127
|
+
rendered answer agree, and it happens on streamed deltas too — a destination arriving eight
|
|
128
|
+
characters at a time is caught before any accumulation of it can render. The audit record names
|
|
129
|
+
the **hostname only**, never the path or query string, because that is where an exfiltration
|
|
130
|
+
payload rides.
|
|
131
|
+
|
|
132
|
+
What it does not close:
|
|
133
|
+
|
|
134
|
+
- **Only inline image syntax is matched.** A reference-style image (`![alt][ref]` with a
|
|
135
|
+
`[ref]: https://…` definition elsewhere) still renders and still fetches. We do not neutralise
|
|
136
|
+
those, because the definition is shared with ordinary links and killing it would break them.
|
|
137
|
+
- **A destination form the pattern does not model gets through** — an alt text containing `]`,
|
|
138
|
+
unusual percent-encodings, or any future renderer-accepted syntax.
|
|
139
|
+
- **Reasoning text is not touched**, because the UI renders it as plain text rather than
|
|
140
|
+
markdown. If that changes upstream, this stops covering it.
|
|
141
|
+
- Raw HTML needs no handling: the renderer keeps `<img …>` as literal text and no HTML enters
|
|
142
|
+
the DOM. That is upstream doing the right thing, and it is why this only has to handle
|
|
143
|
+
markdown.
|
|
144
|
+
- **This is a real behavioural change.** An assistant answer that legitimately links an image
|
|
145
|
+
loses it — the user sees the alt text instead of the picture. That is why it is a switch:
|
|
146
|
+
`remoteImageNeutralization: false` turns it off, and a deployment whose agents produce useful
|
|
147
|
+
images should turn it off and set a CSP at whatever serves the UI instead.
|
|
148
|
+
- **The upstream fix is one `img-src` directive** in a Content-Security-Policy. That covers
|
|
149
|
+
every form, every client, and every channel of this shape at once. This plugin's version
|
|
150
|
+
covers the common syntax on one seam. Prefer the directive.
|
|
151
|
+
|
|
152
|
+
### A tool call rewritten between `tools/pre-execute` and the guard (finding 002)
|
|
153
|
+
|
|
154
|
+
The registry deep-freezes `exec.arguments` but does not freeze the execution object until
|
|
155
|
+
results are notified. A `tools/pre-execute` listener can therefore reassign `exec.arguments` or
|
|
156
|
+
`exec.name` — and reassigning `exec.name` **changes which tool body runs** — while the agent
|
|
157
|
+
loop appended `tool/call` from the model's own response block *before* the waterfall ran. The
|
|
158
|
+
durable record then describes a call that never happened, and nothing warns anyone.
|
|
159
|
+
|
|
160
|
+
This plugin snapshots each call's name and a keyed digest of its arguments at the head of the
|
|
161
|
+
waterfall, and compares in the guard, which runs after the whole waterfall. A mismatch is
|
|
162
|
+
**denied**, with an audit record naming which field changed and, when the name changed, the tool
|
|
163
|
+
the log recorded:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
dsh-dlp denied "dangerous": another mounted plugin rewrote this call's name after the session
|
|
167
|
+
log recorded it, so the log and the presented call describe something other than what would
|
|
168
|
+
have run. The session log records a call to "safe". ...
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
What it does not close:
|
|
172
|
+
|
|
173
|
+
- **It detects; it does not prevent.** Preventing the rewrite means freezing an object this
|
|
174
|
+
plugin does not own, which would break `tools/execute` wrappers that legitimately replace
|
|
175
|
+
`exec.signal`. The tool body does not run, but the mutation still happened.
|
|
176
|
+
- **The snapshot is best-effort, not a floor.** It is registered with `{ prepend: true }`, so it
|
|
177
|
+
runs before listeners registered earlier — but a listener registered *later* with the same
|
|
178
|
+
option runs ahead of it and would be snapshotted after its own rewrite.
|
|
179
|
+
- **A call this plugin never saw is never a finding.** Absence of a snapshot means abstain, so
|
|
180
|
+
scoped dispatches this listener does not receive pass unremarked rather than being denied.
|
|
181
|
+
- **It says nothing about other plugins' decisions.** A `deny` or an `ask` from another
|
|
182
|
+
`tools/pre-execute` listener is ordinary traffic; a deny also skips the guard entirely, so
|
|
183
|
+
nothing here is even consulted.
|
|
184
|
+
- **The upstream fix is better**: two `Object.defineProperty(execution, …, { writable: false })`
|
|
185
|
+
calls at the mint site, or a scheduler-invariant throw naming the offending plugin. Either
|
|
186
|
+
makes the rewrite impossible or fatal at the source instead of denying a call downstream of
|
|
187
|
+
it. This check is not configurable, for the same reason the rest of the floor is not.
|
|
188
|
+
|
|
189
|
+
### The telemetry redactor cannot run under the shipped default (finding 008)
|
|
190
|
+
|
|
191
|
+
A `session-telemetry/record` listener mounts successfully and **silently never runs** under the
|
|
192
|
+
shipped `DSH_TELEMETRY_MODE=DISABLED`, because the coordinator that dispatches the waterfall is
|
|
193
|
+
constructed only in `FULL`/`FEEDBACK_ONLY`. Nothing is exported in that mode, so this is not a
|
|
194
|
+
leak — it is a verification trap: you mount a redactor, see it mount, and have verified nothing.
|
|
195
|
+
|
|
196
|
+
When `telemetryRedaction` is on, this plugin reads the mounted backend's own `sharing`
|
|
197
|
+
disclosure and reports on `process.stderr` **and** `ctx.logger` when the seam will never
|
|
198
|
+
dispatch:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
dsh-dlp: telemetryRedaction is enabled, but the mounted session-telemetry backend reports
|
|
202
|
+
sharing "disabled", so nothing dispatches the session-telemetry/record waterfall and this
|
|
203
|
+
plugin's telemetry redaction never runs. Nothing is exported in this state, so this is not a
|
|
204
|
+
leak — it means the redaction rules are unverified, and they begin running the moment
|
|
205
|
+
telemetry is turned on. Informational only: the plugin's other seams are unaffected.
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
What it does not close:
|
|
209
|
+
|
|
210
|
+
- **It is informational and never fatal.** `DISABLED` is the safe default and the right posture
|
|
211
|
+
for most deployments; the plugin mounts and every other seam runs normally.
|
|
212
|
+
- **It reads a disclosure, not the environment.** `DSH_TELEMETRY_MODE` is only the base
|
|
213
|
+
bundle's default expression for a `mode` a deployment can also set directly, so guessing at
|
|
214
|
+
the variable would be wrong. If a backend discloses `full` or `feedback-only` while
|
|
215
|
+
dispatching nothing, this says nothing.
|
|
216
|
+
- **A backend that mounts after this plugin is answered late.** The check runs at mount if the
|
|
217
|
+
service is already there and otherwise at the first session event, because absence at mount
|
|
218
|
+
cannot be told apart from a load order.
|
|
219
|
+
- **The upstream fix is better**: warn at mount when a `session-telemetry/record` hook exists
|
|
220
|
+
under `DISABLED`, or construct the coordinator unconditionally and drop after the waterfall.
|
|
221
|
+
Either makes the trap visible for every listener, not only ours.
|
|
54
222
|
|
|
55
223
|
---
|
|
56
224
|
|
|
@@ -96,6 +264,7 @@ dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
|
|
|
96
264
|
breadthTier: true
|
|
97
265
|
resultRedaction: true
|
|
98
266
|
telemetryRedaction: true
|
|
267
|
+
remoteImageNeutralization: true
|
|
99
268
|
redactTelemetryWorkspacePaths: true
|
|
100
269
|
```
|
|
101
270
|
|
|
@@ -129,11 +298,11 @@ raiseSeverity:
|
|
|
129
298
|
enable: [telemetryRedaction]
|
|
130
299
|
```
|
|
131
300
|
|
|
132
|
-
Any other key, and any downgrade, makes the **whole file invalid**: it is
|
|
133
|
-
deployment's logger
|
|
134
|
-
`removeCredentialPaths`, and no way to redirect the audit sink. The file is
|
|
135
|
-
`js-yaml` under `JSON_SCHEMA`, so a `!!js/function` tag is a parse error rather
|
|
136
|
-
execution, and it never goes near the Cordis loader.
|
|
301
|
+
Any other key, and any downgrade, makes the **whole file invalid**: it is reported on
|
|
302
|
+
`process.stderr` and the deployment's logger, then ignored, never obeyed in part. There is no
|
|
303
|
+
`disable`, no `removeCredentialPaths`, and no way to redirect the audit sink. The file is
|
|
304
|
+
parsed with `js-yaml` under `JSON_SCHEMA`, so a `!!js/function` tag is a parse error rather
|
|
305
|
+
than code execution, and it never goes near the Cordis loader.
|
|
137
306
|
|
|
138
307
|
A missing `policyFile` is not an error — it means the workspace ships no policy. The
|
|
139
308
|
recommended value is workspace-relative, so failing the mount would stop `dsh` from starting in
|
|
@@ -159,10 +328,24 @@ directories (but not `.env.example`), anything under `.ssh/`,
|
|
|
159
328
|
documentation extensions are excluded from that last rule, so `src/auth/token.ts` stays
|
|
160
329
|
readable.
|
|
161
330
|
|
|
162
|
-
Also denied: this plugin's own `redactionKeyFile` and `auditLog
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
331
|
+
Also denied for every tool: this plugin's own `redactionKeyFile` and `auditLog`.
|
|
332
|
+
|
|
333
|
+
**`$DSH_HOME` is split by direction.** Every *write* under the harness home is denied, for
|
|
334
|
+
every tool: editing a profile's `cordis.yml` mounts an arbitrary plugin, which is the exact
|
|
335
|
+
threat that makes the directory worth protecting. *Reads* are denied only where the contents
|
|
336
|
+
are credentials — `$DSH_HOME/.credentials.yaml`, `$DSH_HOME/sessions/**`, `$DSH_HOME/.env`,
|
|
337
|
+
this plugin's key file and audit log, and any `*.key` — so the installed plugin tree under
|
|
338
|
+
`profiles/node_modules/` and every profile manifest stay readable. A blanket read denial there
|
|
339
|
+
made debugging a plugin, reading a profile, and running the sibling `dsh-plugin-inspector`
|
|
340
|
+
against an installed tree impossible, with a message saying the denial could not be overridden.
|
|
341
|
+
|
|
342
|
+
Which side of that split a call lands on is decided by the tool's name, from a table of tools
|
|
343
|
+
that can only look: `read`, `read_image`, `glob`, `grep`, `lsp`, the session-query tools,
|
|
344
|
+
`job_list`, `job_output`, `terminal_list`, `terminal_read`, `list_agents`, `get_goal`.
|
|
345
|
+
Every other name — every shell, every editor, every `mcp__*` tool, and any tool this build has
|
|
346
|
+
never heard of — is treated as able to write, so a new tool is denied until it is classified.
|
|
347
|
+
A shell is never on the read side even for a command that only reads: a shell that can `cat` a
|
|
348
|
+
profile can also rewrite it.
|
|
166
349
|
|
|
167
350
|
Paths are normalised first — `..` traversal, `~`, Windows separators, quoting and a trailing
|
|
168
351
|
slash do not evade the table — and then resolved with `realpathSync`, so a symlink named
|
|
@@ -228,10 +411,13 @@ is the only decision that replaces the whole result, so it is the only way to dr
|
|
|
228
411
|
|
|
229
412
|
Two consequences worth knowing:
|
|
230
413
|
|
|
231
|
-
- Replacing a value re-validates it against the tool's `output.schema
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
414
|
+
- Replacing a value re-validates it against the tool's `output.schema`, and a schema that pins
|
|
415
|
+
that string — an `enum`, a `const`, a `oneOf` branch it selects — would reject the
|
|
416
|
+
placeholder. The plugin asks that question first and withholds the result with the message
|
|
417
|
+
above, rather than letting the registry raise a `ToolOutputError` that names a validation
|
|
418
|
+
failure and tells the model nothing it can act on. The call still fails; it fails
|
|
419
|
+
comprehensibly. Where the plugin cannot answer the question — no schema resolved, or a
|
|
420
|
+
schema whose own value it cannot validate — the registry decides as before.
|
|
235
421
|
- Redaction is per-detection, and each span grows to the nearest delimiter — whitespace,
|
|
236
422
|
quotes, `=`, `:`, `,`, brackets. A line of minified JSON loses the field that matched, not
|
|
237
423
|
the whole line.
|
|
@@ -258,6 +444,51 @@ A tool result is scanned twice: each of its strings on its own by tier 1, and al
|
|
|
258
444
|
joined by newlines through both tiers. The joined pass finds what no single string reproduces —
|
|
259
445
|
a PEM block arriving as one line per array element, which is exactly the shape `read` produces.
|
|
260
446
|
|
|
447
|
+
### Invisible characters
|
|
448
|
+
|
|
449
|
+
Tier 1 also looks for characters that hide text from the person reading a tool result while
|
|
450
|
+
the model still reads it. The harness strips directional controls in exactly one place —
|
|
451
|
+
session titles — and never on the tool-result path.
|
|
452
|
+
|
|
453
|
+
| Class | Code points | What happens |
|
|
454
|
+
|---|---|---|
|
|
455
|
+
| Tags block | `U+E0000–U+E007F` | replaced |
|
|
456
|
+
| Bidi overrides and isolates | `U+202A–U+202E`, `U+2066–U+2069` | replaced |
|
|
457
|
+
| Zero-width | `U+200B–U+200D`, `U+2060`, `U+FEFF` | counted only |
|
|
458
|
+
| Bidi marks | `U+061C`, `U+200E–U+200F` | counted only |
|
|
459
|
+
| Variation selectors | `U+FE00–U+FE0F`, `U+E0100–U+E01EF` | counted only |
|
|
460
|
+
|
|
461
|
+
The first two have no legitimate use in tool output — the Tags block is a full invisible ASCII
|
|
462
|
+
alphabet, which is what makes it the standard carrier for a hidden instruction. The last three
|
|
463
|
+
do: `U+200D` joins an emoji sequence and a variation selector picks a glyph, so replacing them
|
|
464
|
+
would corrupt ordinary text. They are counted in the audit record's `unicode` field and left
|
|
465
|
+
alone, as a `medium` finding.
|
|
466
|
+
|
|
467
|
+
Every class is `medium`, below the severity at which the guard floor denies, so an invisible
|
|
468
|
+
character is never turned into a denial. A replaced run becomes an ordinary placeholder and,
|
|
469
|
+
unlike a secret, is replaced exactly: an invisible character is not widened to its surrounding
|
|
470
|
+
delimiters, so the visible word it hid inside survives.
|
|
471
|
+
|
|
472
|
+
**A homoglyph defeats all of this**, and every other rule in this plugin. A Cyrillic `а` in
|
|
473
|
+
`аdmin` is a normal, visible, legitimately-encoded character; detecting it means UTS #39
|
|
474
|
+
confusable tables, which is a data set and a different cost class. This plugin does not attempt
|
|
475
|
+
it, and no rule here should be read as covering it.
|
|
476
|
+
|
|
477
|
+
Measured cost of the invisible-character scan over 512 KB, median of 30 runs on an i9-12900H
|
|
478
|
+
under Node 22.23.2:
|
|
479
|
+
|
|
480
|
+
| Input | Cost |
|
|
481
|
+
|---|---|
|
|
482
|
+
| clean Latin-1 text | 0.002 ms |
|
|
483
|
+
| one hidden instruction (69 characters) | 0.355 ms |
|
|
484
|
+
| 7,653 separate runs | 7.9 ms |
|
|
485
|
+
| 512 KB of alternating invisible characters (524,286 runs) | 56–113 ms |
|
|
486
|
+
|
|
487
|
+
Clean text is free because every character in the table is above `U+00FF`: the regular
|
|
488
|
+
expression engine rejects a Latin-1 string on its encoding without scanning it. The last row is
|
|
489
|
+
a crafted input, not a plausible one, and it is the only case that leaves the ≤10 ms per result
|
|
490
|
+
budget; `maxScanBytes` caps tier 2 only, so tier 1 always sees the whole result.
|
|
491
|
+
|
|
261
492
|
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
|
|
262
493
|
at 512 KB. `maxScanBytes` caps **tier 2 only**, once per result, over the joined rendering;
|
|
263
494
|
tier 1 always scans everything. When tier 2 saw less than the whole result the audit record
|
|
@@ -299,12 +530,57 @@ its own identity.
|
|
|
299
530
|
}
|
|
300
531
|
```
|
|
301
532
|
|
|
302
|
-
`kind` is one of `guard-deny`, `pre-execute-deny`, `
|
|
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
|
|
535
|
+
`mutatedFields` and, when a tool substitution happened, the `originalTool` the log recorded. An
|
|
536
|
+
`assistant-image-neutralized` record carries `host` — the hostname of the blocked destination
|
|
537
|
+
and nothing else from the URL.
|
|
538
|
+
A `result-redaction` record may also carry `unicode`, a count of invisible-character runs per
|
|
539
|
+
class — counts only, because a hidden instruction is exactly the content this file must not
|
|
540
|
+
repeat. A record is written whenever there is something to say, including a result that was
|
|
541
|
+
only counted and a result whose tier-2 scan was truncated.
|
|
303
542
|
A record carries no free-text reason: the spans are the whole description of what matched, so
|
|
304
543
|
nothing built from a candidate path or command line can reach the file. An audit write failure
|
|
305
|
-
is
|
|
544
|
+
is reported and swallowed rather than turned into a denial: the sink is evidence, not
|
|
306
545
|
enforcement, and a full disk should not take the agent down.
|
|
307
546
|
|
|
547
|
+
Reported means `process.stderr` **and** `ctx.logger`, for that failure and for an invalid
|
|
548
|
+
policy file. The logger alone is not enough: its default exporter is an in-memory 1000-entry
|
|
549
|
+
ring buffer and no shipped bundle mounts a console exporter, so a message sent only there is
|
|
550
|
+
invisible on a stock install. `process.stderr` is what the headless runner itself writes to.
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## Reading the audit log
|
|
555
|
+
|
|
556
|
+
The package installs a `dsh-dlp` command that reads the JSONL sink and summarises it. It
|
|
557
|
+
imports nothing from the harness, so it runs wherever the package is installed, with no profile
|
|
558
|
+
and no `dsh` on the path:
|
|
559
|
+
|
|
560
|
+
```sh
|
|
561
|
+
dsh-dlp report # everything in $DSH_HOME/dsh-dlp.audit.jsonl
|
|
562
|
+
dsh-dlp report --since 24h # or an ISO timestamp
|
|
563
|
+
dsh-dlp report --session <id>
|
|
564
|
+
dsh-dlp report --would-have # only the calls that were let through
|
|
565
|
+
dsh-dlp report --log /var/log/dsh-dlp.audit.jsonl
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
It prints counts by decision, by rule, by tool and by invisible-character class, then the ten
|
|
569
|
+
most recent decisions. `--would-have` drops the denials and leaves the redactions and the
|
|
570
|
+
invisible-character findings: those are the calls that ran, with their results rewritten, and
|
|
571
|
+
they are what a policy that denied instead of rewriting would have blocked.
|
|
572
|
+
|
|
573
|
+
The sink is append-only and a run can be interrupted mid-append, so a line that does not parse
|
|
574
|
+
as a record is counted and reported rather than trusted. If the deployment set `auditLog` to
|
|
575
|
+
somewhere other than the default, pass `--log`; the command says which file it looked at.
|
|
576
|
+
|
|
577
|
+
A plugin installed into a profile puts its bin in that profile's `node_modules/.bin`, which is
|
|
578
|
+
not on `PATH`. Run it from there, or install the package globally:
|
|
579
|
+
|
|
580
|
+
```sh
|
|
581
|
+
"$DSH_HOME/profiles/<name>/node_modules/.bin/dsh-dlp" report
|
|
582
|
+
```
|
|
583
|
+
|
|
308
584
|
---
|
|
309
585
|
|
|
310
586
|
## Development
|
package/SECURITY.md
CHANGED
|
@@ -29,7 +29,7 @@ otherwise.
|
|
|
29
29
|
|
|
30
30
|
This plugin is **not a containment boundary**. It runs in-process at the agent's own uid, so
|
|
31
31
|
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
|
|
32
|
+
documented limits, not vulnerabilities — they are described in README.md:
|
|
33
33
|
|
|
34
34
|
- shell-command obfuscation defeating the `bash` path arm (globbing, quoting, substitution, a
|
|
35
35
|
different binary);
|