dsh-dlp 0.2.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 +156 -5
- package/SECURITY.md +1 -1
- package/cordis.patch.yml +1 -0
- package/lib/cli.js +1 -1
- package/lib/images.js +183 -0
- package/lib/index.js +81 -1
- package/lib/mutation.js +102 -0
- package/lib/policy.js +9 -1
- package/lib/telemetry.js +29 -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/policy.d.ts +4 -1
- package/lib/types/sink.d.ts +12 -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 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
|
|
@@ -15,7 +15,11 @@ 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. **Writes an audit record for every decision** to its own sink — rule id, rule version,
|
|
19
23
|
offsets, and a keyed hash. Never the secret, and never the path or command that matched.
|
|
20
24
|
`dsh-dlp report` reads that sink back.
|
|
21
25
|
|
|
@@ -34,6 +38,12 @@ egress firewalling. Use this alongside them, not instead of them.
|
|
|
34
38
|
|
|
35
39
|
More limits worth stating up front:
|
|
36
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.
|
|
37
47
|
- **The shell-command arm is advisory pattern-matching.** A `bash` command line is split on
|
|
38
48
|
shell-ish separators and each token is tested as a path. That catches an unobfuscated
|
|
39
49
|
`cat ~/.ssh/id_rsa`. It catches nothing that tries: `cat ~/.netr?` (one glob character),
|
|
@@ -50,7 +60,8 @@ More limits worth stating up front:
|
|
|
50
60
|
- **Already-logged history cannot be rewritten; a not-yet-logged inbound message can.** At
|
|
51
61
|
`llm/stream` the options are deep-frozen and `next()` takes no arguments, so a request the
|
|
52
62
|
agent has assembled goes out as it stands and a secret already in the conversation reaches
|
|
53
|
-
the provider.
|
|
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
|
|
54
65
|
returning `{ kind: 'enter'; messages }`, and the only production append of `user/message`
|
|
55
66
|
happens *after* it, so a message arriving from outside can still be rewritten before it is
|
|
56
67
|
logged or presented. This release does not do that; it is recorded here because the earlier
|
|
@@ -71,8 +82,143 @@ More limits worth stating up front:
|
|
|
71
82
|
is 100% for anything up to 22 characters, which is most of the credential formats worth
|
|
72
83
|
catching. A detector that fires on the long ones the prefix rules already catch and misses
|
|
73
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.
|
|
74
98
|
|
|
75
|
-
|
|
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.
|
|
76
222
|
|
|
77
223
|
---
|
|
78
224
|
|
|
@@ -118,6 +264,7 @@ dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
|
|
|
118
264
|
breadthTier: true
|
|
119
265
|
resultRedaction: true
|
|
120
266
|
telemetryRedaction: true
|
|
267
|
+
remoteImageNeutralization: true
|
|
121
268
|
redactTelemetryWorkspacePaths: true
|
|
122
269
|
```
|
|
123
270
|
|
|
@@ -383,7 +530,11 @@ its own identity.
|
|
|
383
530
|
}
|
|
384
531
|
```
|
|
385
532
|
|
|
386
|
-
`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.
|
|
387
538
|
A `result-redaction` record may also carry `unicode`, a count of invisible-character runs per
|
|
388
539
|
class — counts only, because a hidden instruction is exactly the content this file must not
|
|
389
540
|
repeat. A record is written whenever there is something to say, including a result that was
|
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);
|
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. */
|
package/lib/images.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutralising remote markdown images in assistant output, on the `llm/stream`
|
|
3
|
+
* waterfall.
|
|
4
|
+
*
|
|
5
|
+
* The web UI renders any absolute `http:`/`https:` markdown image a model
|
|
6
|
+
* emits as a real `<img src>`, and the harness sets no Content-Security-Policy,
|
|
7
|
+
* so the fetch happens in the user's browser where no host-side listener can
|
|
8
|
+
* see it. This module rewrites the destination out of the assistant's text
|
|
9
|
+
* before it becomes an `assistant/chunk` or `assistant/message` session event,
|
|
10
|
+
* so the log and the rendered answer stay in agreement.
|
|
11
|
+
*
|
|
12
|
+
* Two properties this module exists to hold:
|
|
13
|
+
*
|
|
14
|
+
* - **A destination split across chunks is still caught.** The mock and real
|
|
15
|
+
* adapters both emit text in small deltas, so `` is
|
|
16
|
+
* routinely spread over several of them and the browser renders the
|
|
17
|
+
* accumulation. Text that could still be the start of an image is held back
|
|
18
|
+
* until it either completes or exceeds {@link MAX_HELD_CHARACTERS}.
|
|
19
|
+
* - **Only the destination is replaced.** The alt text survives, so the
|
|
20
|
+
* sentence the model wrote still reads, and the renderer's own
|
|
21
|
+
* non-absolute-URL arm shows that alt text instead of fetching anything.
|
|
22
|
+
*
|
|
23
|
+
* This does not close the channel. It matches inline image syntax only:
|
|
24
|
+
* reference-style images, an alt text carrying a `]`, and any destination form
|
|
25
|
+
* the pattern does not model still reach the renderer. Raw HTML needs no
|
|
26
|
+
* handling — the renderer keeps it as literal text and no HTML enters the DOM
|
|
27
|
+
* (`packages/client/ui-primitives/src/markdown/render.tsx:261-263`). The
|
|
28
|
+
* upstream fix is one `img-src` directive.
|
|
29
|
+
* @module dsh-dlp/images
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Destination substituted for a remote image URL.
|
|
33
|
+
*
|
|
34
|
+
* It is deliberately not a URL: `new URL()` throws on it, which is the
|
|
35
|
+
* renderer's own "not an absolute destination" arm, and that arm renders the
|
|
36
|
+
* alt text as a `<span>` instead of emitting an `<img>`.
|
|
37
|
+
*/
|
|
38
|
+
export const BLOCKED_IMAGE_DESTINATION = 'dsh-dlp-blocked-remote-image';
|
|
39
|
+
/**
|
|
40
|
+
* One inline image: alt text, destination, optional title.
|
|
41
|
+
*
|
|
42
|
+
* The destination is either an angle-bracketed form or a run of characters
|
|
43
|
+
* with no whitespace and no parenthesis, which is what CommonMark accepts
|
|
44
|
+
* without balanced-parenthesis nesting.
|
|
45
|
+
*/
|
|
46
|
+
const INLINE_IMAGE = /!\[([^\]]*)\]\(\s*(<[^<>\n]*>|[^\s()]*)((?:\s+(?:"[^"]*"|'[^']*'|\([^()]*\)))?)\s*\)/g;
|
|
47
|
+
/**
|
|
48
|
+
* Text that could still become an inline image once more of the stream
|
|
49
|
+
* arrives: an alt text still open, a closed alt text followed by `(`, or a
|
|
50
|
+
* destination not yet closed.
|
|
51
|
+
*/
|
|
52
|
+
const PARTIAL_IMAGE = /^!\[[^\]]*(?:\](?:\((?:\s*(?:<[^<>\n]*|[^\s()]*))?)?)?$/;
|
|
53
|
+
/**
|
|
54
|
+
* Longest suffix held back waiting for an image to complete.
|
|
55
|
+
*
|
|
56
|
+
* Held text is text the user cannot see yet, so the wait is bounded: past this
|
|
57
|
+
* many characters the suffix is emitted as it stands and a destination that
|
|
58
|
+
* completes later is caught only by the assembled block. A protocol bound on
|
|
59
|
+
* this module's own buffering, not a deployment choice.
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_HELD_CHARACTERS = 4096;
|
|
62
|
+
/**
|
|
63
|
+
* The hostname of an absolute `http(s)` destination.
|
|
64
|
+
* @param destination - the image destination exactly as the model wrote it.
|
|
65
|
+
* @returns the hostname, or `undefined` when the destination is not an absolute HTTP(S) URL.
|
|
66
|
+
*/
|
|
67
|
+
function remoteHost(destination) {
|
|
68
|
+
const trimmed = destination.startsWith('<') && destination.endsWith('>')
|
|
69
|
+
? destination.slice(1, -1)
|
|
70
|
+
: destination;
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(trimmed);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// The only failure mode for a string: not an absolute URL, which the
|
|
77
|
+
// renderer also refuses, so there is nothing to neutralise.
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.hostname : undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Replace every absolute HTTP(S) inline image destination in one string.
|
|
84
|
+
* @param text - assistant text, whole or partial.
|
|
85
|
+
* @returns the rewritten text and the hosts whose destinations were replaced.
|
|
86
|
+
*/
|
|
87
|
+
export function neutralizeRemoteImages(text) {
|
|
88
|
+
const hosts = [];
|
|
89
|
+
const rewritten = text.replace(INLINE_IMAGE, (match, alt, destination, title) => {
|
|
90
|
+
const host = remoteHost(destination);
|
|
91
|
+
if (host === undefined)
|
|
92
|
+
return match;
|
|
93
|
+
hosts.push(host);
|
|
94
|
+
return ``;
|
|
95
|
+
});
|
|
96
|
+
return { text: rewritten, hosts };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Where the held suffix of a partially streamed string starts.
|
|
100
|
+
* @param text - everything accumulated for one block and not yet emitted.
|
|
101
|
+
* @returns the offset to emit up to; the string's length when nothing is held.
|
|
102
|
+
*/
|
|
103
|
+
export function heldSuffixStart(text) {
|
|
104
|
+
const marker = text.lastIndexOf('![');
|
|
105
|
+
if (marker !== -1 && text.length - marker <= MAX_HELD_CHARACTERS && PARTIAL_IMAGE.test(text.slice(marker))) {
|
|
106
|
+
return marker;
|
|
107
|
+
}
|
|
108
|
+
return text.endsWith('!') ? text.length - 1 : text.length;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Wrap one model stream, replacing remote image destinations in its text.
|
|
112
|
+
*
|
|
113
|
+
* Text deltas are rewritten as they pass, with a possible image start held
|
|
114
|
+
* back until it resolves, and the assembled block on `block-end` — which is
|
|
115
|
+
* what the agent loop turns into the assistant message — is rewritten too. A
|
|
116
|
+
* held suffix is always flushed as a delta before the block closes and before
|
|
117
|
+
* the terminal finish, so no text is lost and the emitted chunks still satisfy
|
|
118
|
+
* the stream grammar.
|
|
119
|
+
* @param source - the stream from the rest of the waterfall.
|
|
120
|
+
* @param onNeutralized - notified once per host per text block.
|
|
121
|
+
* @returns the rewritten stream.
|
|
122
|
+
*/
|
|
123
|
+
export async function* neutralizeImageStream(source, onNeutralized) {
|
|
124
|
+
const held = new Map();
|
|
125
|
+
const reported = new Map();
|
|
126
|
+
/** Report each host once per block: the deltas and the assembled block carry the same text. */
|
|
127
|
+
const report = (index, hosts) => {
|
|
128
|
+
let seen = reported.get(index);
|
|
129
|
+
if (seen === undefined) {
|
|
130
|
+
seen = new Set();
|
|
131
|
+
reported.set(index, seen);
|
|
132
|
+
}
|
|
133
|
+
for (const host of hosts) {
|
|
134
|
+
if (seen.has(host))
|
|
135
|
+
continue;
|
|
136
|
+
seen.add(host);
|
|
137
|
+
onNeutralized(host);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
/** Emit whatever one block is still holding, so a close or a finish loses nothing. */
|
|
141
|
+
function* flush(index) {
|
|
142
|
+
const pending = held.get(index);
|
|
143
|
+
held.delete(index);
|
|
144
|
+
if (pending !== undefined && pending.length > 0)
|
|
145
|
+
yield { type: 'text-delta', index, text: pending };
|
|
146
|
+
}
|
|
147
|
+
for await (const chunk of source) {
|
|
148
|
+
switch (chunk.type) {
|
|
149
|
+
case 'text-delta': {
|
|
150
|
+
const { text, hosts } = neutralizeRemoteImages((held.get(chunk.index) ?? '') + chunk.text);
|
|
151
|
+
report(chunk.index, hosts);
|
|
152
|
+
const cut = heldSuffixStart(text);
|
|
153
|
+
held.set(chunk.index, text.slice(cut));
|
|
154
|
+
if (cut > 0)
|
|
155
|
+
yield { ...chunk, text: text.slice(0, cut) };
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case 'block-end': {
|
|
159
|
+
yield* flush(chunk.index);
|
|
160
|
+
if (chunk.block.type !== 'text') {
|
|
161
|
+
yield chunk;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
const { text, hosts } = neutralizeRemoteImages(chunk.block.text);
|
|
165
|
+
report(chunk.index, hosts);
|
|
166
|
+
yield text === chunk.block.text ? chunk : { ...chunk, block: { ...chunk.block, text } };
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'finish': {
|
|
170
|
+
for (const index of [...held.keys()])
|
|
171
|
+
yield* flush(index);
|
|
172
|
+
yield chunk;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
default:
|
|
176
|
+
yield chunk;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Only a stream that ended without a terminal finish reaches this: the
|
|
180
|
+
// grammar forbids emitting after one, so the flush above already ran.
|
|
181
|
+
for (const index of [...held.keys()])
|
|
182
|
+
yield* flush(index);
|
|
183
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
15
15
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
16
16
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
17
|
+
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
|
18
|
+
* assistant output, before the text becomes a session event.
|
|
19
|
+
*
|
|
20
|
+
* Three of those registrations mitigate defects in the harness rather than in
|
|
21
|
+
* a deployment's own configuration: the missing Content-Security-Policy behind
|
|
22
|
+
* (5), the mutable execution object behind the guard's mutation check, and the
|
|
23
|
+
* silently inert telemetry seam behind the notice reported at mount. Each one
|
|
24
|
+
* is partial, none closes its channel, and README.md says so beside the
|
|
25
|
+
* feature.
|
|
17
26
|
*
|
|
18
27
|
* This plugin is not a containment boundary. It runs in-process at the agent's
|
|
19
28
|
* own uid; anything the agent can execute can read the same files the guard
|
|
@@ -25,8 +34,10 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
25
34
|
import { loadRepoPolicy, resolvePolicy } from "./policy.js";
|
|
26
35
|
import { SpanHasher } from "./redaction.js";
|
|
27
36
|
import { safeEvaluateGuard } from "./guard.js";
|
|
37
|
+
import { neutralizeImageStream } from "./images.js";
|
|
38
|
+
import { ExecutionSnapshots, mutationReason } from "./mutation.js";
|
|
28
39
|
import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
|
|
29
|
-
import { redactRecord } from "./telemetry.js";
|
|
40
|
+
import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
|
|
30
41
|
import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
|
|
31
42
|
export { Config } from "./policy.js";
|
|
32
43
|
/** Display metadata; labels the plugin in Cordis diagnostics. */
|
|
@@ -84,6 +95,16 @@ function report(ctx, message) {
|
|
|
84
95
|
ctx.logger.error(message);
|
|
85
96
|
process.stderr.write(`${message}\n`);
|
|
86
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Report something the operator should know that is not a fault, on the same
|
|
100
|
+
* two channels and for the same reason as {@link report}.
|
|
101
|
+
* @param ctx - the plugin's context, used for its logger.
|
|
102
|
+
* @param message - the whole line to report.
|
|
103
|
+
*/
|
|
104
|
+
function notice(ctx, message) {
|
|
105
|
+
ctx.logger.warn(message);
|
|
106
|
+
process.stderr.write(`${message}\n`);
|
|
107
|
+
}
|
|
87
108
|
/**
|
|
88
109
|
* Load the repo-local policy tier, if the deployment named one.
|
|
89
110
|
*
|
|
@@ -138,7 +159,30 @@ export function apply(ctx, config) {
|
|
|
138
159
|
...position === undefined ? {} : { turn: position.turn, step: position.step },
|
|
139
160
|
};
|
|
140
161
|
};
|
|
162
|
+
const snapshots = new ExecutionSnapshots(hasher);
|
|
163
|
+
/**
|
|
164
|
+
* Report the telemetry seam's state once.
|
|
165
|
+
*
|
|
166
|
+
* At mount only a backend that is already there answers the question: the
|
|
167
|
+
* backend can load after this plugin, and calling that absence "inert" would
|
|
168
|
+
* be a false alarm. `conclusive` marks the later call, made once the harness
|
|
169
|
+
* is running sessions, where an absent backend really means no dispatcher.
|
|
170
|
+
*/
|
|
171
|
+
let telemetrySeamReported = false;
|
|
172
|
+
const discloseTelemetrySeam = (conclusive) => {
|
|
173
|
+
if (telemetrySeamReported)
|
|
174
|
+
return;
|
|
175
|
+
const backend = ctx.get('sessionTelemetry');
|
|
176
|
+
if (backend === undefined && !conclusive)
|
|
177
|
+
return;
|
|
178
|
+
telemetrySeamReported = true;
|
|
179
|
+
const line = telemetrySeamNotice(backend?.sharing);
|
|
180
|
+
if (line !== undefined)
|
|
181
|
+
notice(ctx, line);
|
|
182
|
+
};
|
|
141
183
|
ctx.on('session/event', (_session, event) => {
|
|
184
|
+
if (policy.telemetryRedaction)
|
|
185
|
+
discloseTelemetrySeam(true);
|
|
142
186
|
if (event.type === 'tool/call') {
|
|
143
187
|
correlator.note(event.data.callId, { turn: event.data.turn, step: event.data.step });
|
|
144
188
|
return;
|
|
@@ -147,9 +191,32 @@ export function apply(ctx, config) {
|
|
|
147
191
|
correlator.forget(event.data.message.source.callId);
|
|
148
192
|
}
|
|
149
193
|
});
|
|
194
|
+
// Snapshot each call before the rest of the waterfall can rewrite it. The
|
|
195
|
+
// prepend is best-effort by construction: a listener registered later with
|
|
196
|
+
// the same option runs ahead of this one.
|
|
197
|
+
ctx.on('tools/pre-execute', (exec, next) => {
|
|
198
|
+
snapshots.record(exec);
|
|
199
|
+
return next();
|
|
200
|
+
}, { prepend: true });
|
|
150
201
|
// The floor. Registered on a plain context so it applies globally: to every
|
|
151
202
|
// agent, every `run_code` inner sub-call, and every subagent child.
|
|
152
203
|
ctx.effect(() => ctx.tools.guard((exec) => {
|
|
204
|
+
// Integrity first: a call whose name or arguments changed after `tool/call`
|
|
205
|
+
// was appended is denied whatever the policy tables say about it, because
|
|
206
|
+
// the log no longer describes what would run.
|
|
207
|
+
const mutation = snapshots.detect(exec);
|
|
208
|
+
if (mutation !== undefined) {
|
|
209
|
+
sink.write({
|
|
210
|
+
v: RECORD_VERSION,
|
|
211
|
+
time: new Date().toISOString(),
|
|
212
|
+
kind: 'execution-mutation',
|
|
213
|
+
decisionId: newDecisionId(),
|
|
214
|
+
...identity(exec),
|
|
215
|
+
mutatedFields: mutation.fields,
|
|
216
|
+
...mutation.fields.includes('name') ? { originalTool: mutation.originalTool } : {},
|
|
217
|
+
});
|
|
218
|
+
return mutationReason(exec, mutation);
|
|
219
|
+
}
|
|
153
220
|
const verdict = safeEvaluateGuard(exec, policy, hasher);
|
|
154
221
|
if (verdict === undefined)
|
|
155
222
|
return undefined;
|
|
@@ -212,7 +279,20 @@ export function apply(ctx, config) {
|
|
|
212
279
|
return redacted.decision;
|
|
213
280
|
});
|
|
214
281
|
}
|
|
282
|
+
if (policy.remoteImageNeutralization) {
|
|
283
|
+
ctx.on('llm/stream', (options, next) => neutralizeImageStream(next(), (host) => {
|
|
284
|
+
sink.write({
|
|
285
|
+
v: RECORD_VERSION,
|
|
286
|
+
time: new Date().toISOString(),
|
|
287
|
+
kind: 'assistant-image-neutralized',
|
|
288
|
+
decisionId: newDecisionId(),
|
|
289
|
+
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
|
|
290
|
+
host,
|
|
291
|
+
});
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
215
294
|
if (policy.telemetryRedaction) {
|
|
295
|
+
discloseTelemetrySeam(false);
|
|
216
296
|
ctx.on('session-telemetry/record', (_record, next) => {
|
|
217
297
|
// Throwing here withholds this one record; the coordinator contains it
|
|
218
298
|
// and the agent loop never sees the failure. That is the fail-closed
|
package/lib/mutation.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecting a tool call that was rewritten after it was logged.
|
|
3
|
+
*
|
|
4
|
+
* The registry deep-freezes `exec.arguments` but does not freeze the execution
|
|
5
|
+
* object until results are notified, so a `tools/pre-execute` listener can
|
|
6
|
+
* reassign `exec.arguments` or `exec.name` — and reassigning `exec.name`
|
|
7
|
+
* changes which tool body runs. The agent loop appended `tool/call` from the
|
|
8
|
+
* model's own response block before the waterfall ran, so nothing in the
|
|
9
|
+
* session log records the change: the durable record then describes a
|
|
10
|
+
* different call than the one about to execute.
|
|
11
|
+
*
|
|
12
|
+
* This module snapshots the call as early in the waterfall as it can and
|
|
13
|
+
* compares in the guard, which runs after the whole waterfall and cannot be
|
|
14
|
+
* out-ordered. It is detection, not prevention: preventing the rewrite would
|
|
15
|
+
* mean freezing an object this plugin does not own, and the snapshot itself is
|
|
16
|
+
* best-effort — a later `{ prepend: true }` registration runs ahead of ours and
|
|
17
|
+
* would be snapshotted after its own rewrite.
|
|
18
|
+
* @module dsh-dlp/mutation
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Render a JSON value with object keys in a fixed order, so two equal argument
|
|
22
|
+
* sets hash equally whatever order a listener rebuilt them in.
|
|
23
|
+
* @param value - the argument value; JSON-serializable by the registry's own snapshot step.
|
|
24
|
+
* @returns a canonical string for hashing.
|
|
25
|
+
*/
|
|
26
|
+
export function canonicalJson(value) {
|
|
27
|
+
if (Array.isArray(value))
|
|
28
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
29
|
+
if (typeof value === 'object' && value !== null) {
|
|
30
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
31
|
+
}
|
|
32
|
+
// `undefined` has no JSON rendering; it reaches here only for an execution
|
|
33
|
+
// whose arguments never materialized, which the registry fails before the
|
|
34
|
+
// waterfall. A fixed token keeps the digest total either way.
|
|
35
|
+
return JSON.stringify(value) ?? 'undefined';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Remembers what each pending call looked like before the rest of the
|
|
39
|
+
* `tools/pre-execute` waterfall ran.
|
|
40
|
+
*
|
|
41
|
+
* Keyed by the execution object's identity in a `WeakMap`, the way the
|
|
42
|
+
* registry keys its own per-execution state, so the entry is found again in
|
|
43
|
+
* the guard and released with the execution.
|
|
44
|
+
*/
|
|
45
|
+
export class ExecutionSnapshots {
|
|
46
|
+
#snapshots = new WeakMap();
|
|
47
|
+
#hasher;
|
|
48
|
+
/**
|
|
49
|
+
* @param hasher - mints the keyed digest of the arguments; the values themselves are never stored.
|
|
50
|
+
*/
|
|
51
|
+
constructor(hasher) {
|
|
52
|
+
this.#hasher = hasher;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Snapshot one pending call.
|
|
56
|
+
* @param exec - the execution as the earliest listener sees it.
|
|
57
|
+
*/
|
|
58
|
+
record(exec) {
|
|
59
|
+
this.#snapshots.set(exec, { name: exec.name, digest: this.#hasher.hash(canonicalJson(exec.arguments)) });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Compare one call against its snapshot.
|
|
63
|
+
*
|
|
64
|
+
* A call with no snapshot is not a finding: the listener may never have run
|
|
65
|
+
* for it, and reporting absence as mutation would deny calls this plugin
|
|
66
|
+
* simply did not observe.
|
|
67
|
+
* @param exec - the execution as the guard stage sees it.
|
|
68
|
+
* @returns what changed, or `undefined` when nothing did.
|
|
69
|
+
*/
|
|
70
|
+
detect(exec) {
|
|
71
|
+
const snapshot = this.#snapshots.get(exec);
|
|
72
|
+
if (snapshot === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
const fields = [];
|
|
75
|
+
if (exec.name !== snapshot.name)
|
|
76
|
+
fields.push('name');
|
|
77
|
+
if (this.#hasher.hash(canonicalJson(exec.arguments)) !== snapshot.digest)
|
|
78
|
+
fields.push('arguments');
|
|
79
|
+
return fields.length === 0 ? undefined : { fields, originalTool: snapshot.name };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Denial text for a rewritten call.
|
|
84
|
+
*
|
|
85
|
+
* Both tool names are named: a tool name is already in the session log and in
|
|
86
|
+
* every other denial this plugin writes, and naming them is the whole point —
|
|
87
|
+
* the operator needs to know which call the log describes and which one was
|
|
88
|
+
* about to run. No argument value appears.
|
|
89
|
+
* @param exec - the call as the guard sees it, after the rewrite.
|
|
90
|
+
* @param mutation - the fields that changed and the recorded tool name.
|
|
91
|
+
* @returns the model-facing reason.
|
|
92
|
+
*/
|
|
93
|
+
export function mutationReason(exec, mutation) {
|
|
94
|
+
const changed = mutation.fields.join(' and ');
|
|
95
|
+
const renamed = mutation.fields.includes('name')
|
|
96
|
+
? ` The session log records a call to ${JSON.stringify(mutation.originalTool)}.`
|
|
97
|
+
: '';
|
|
98
|
+
return `dsh-dlp denied ${JSON.stringify(exec.name)}: another mounted plugin rewrote this call's ${changed} `
|
|
99
|
+
+ `after the session log recorded it, so the log and the presented call describe something other than what `
|
|
100
|
+
+ `would have run.${renamed} The call is denied because a tool call that cannot be reconstructed from the log `
|
|
101
|
+
+ 'is not auditable. This is a defect in a mounted plugin, not in the call; report it to the deployment operator.';
|
|
102
|
+
}
|
package/lib/policy.js
CHANGED
|
@@ -29,10 +29,17 @@ export const Config = z.object({
|
|
|
29
29
|
breadthTier: z.boolean().default(true),
|
|
30
30
|
resultRedaction: z.boolean().default(true),
|
|
31
31
|
telemetryRedaction: z.boolean().default(true),
|
|
32
|
+
remoteImageNeutralization: z.boolean().default(true),
|
|
32
33
|
redactTelemetryWorkspacePaths: z.boolean().default(true),
|
|
33
34
|
});
|
|
34
35
|
/** Config toggles a repo-local policy may switch on, and never off. */
|
|
35
|
-
const ENABLEABLE = [
|
|
36
|
+
const ENABLEABLE = [
|
|
37
|
+
'breadthTier',
|
|
38
|
+
'resultRedaction',
|
|
39
|
+
'telemetryRedaction',
|
|
40
|
+
'remoteImageNeutralization',
|
|
41
|
+
'redactTelemetryWorkspacePaths',
|
|
42
|
+
];
|
|
36
43
|
/** Keys a repo-local policy file may carry; anything else fails the load. */
|
|
37
44
|
const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
|
|
38
45
|
/** Payload version this package writes and accepts for repo-local policy files. */
|
|
@@ -268,6 +275,7 @@ export function resolvePolicy(config, repo) {
|
|
|
268
275
|
breadthTier: enabled('breadthTier'),
|
|
269
276
|
resultRedaction: enabled('resultRedaction'),
|
|
270
277
|
telemetryRedaction: enabled('telemetryRedaction'),
|
|
278
|
+
remoteImageNeutralization: enabled('remoteImageNeutralization'),
|
|
271
279
|
redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
|
|
272
280
|
};
|
|
273
281
|
}
|
package/lib/telemetry.js
CHANGED
|
@@ -27,6 +27,35 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { scanSync } from "./detectors.js";
|
|
29
29
|
import { placeholderFor, redactJson, redactText } from "./redaction.js";
|
|
30
|
+
/**
|
|
31
|
+
* What to tell the operator when the redaction seam will never dispatch.
|
|
32
|
+
*
|
|
33
|
+
* A `session-telemetry/record` listener mounts successfully and never runs
|
|
34
|
+
* unless a backend built a coordinator, and the shipped default builds none:
|
|
35
|
+
* the mode is `DISABLED`, so nothing is exported and nothing is dispatched.
|
|
36
|
+
* That is the safe posture, not a leak — but an operator who mounts a redactor
|
|
37
|
+
* under it sees every signal of success and has verified nothing. The
|
|
38
|
+
* backend's own `sharing` disclosure is the resolved answer, so this never
|
|
39
|
+
* guesses at `DSH_TELEMETRY_MODE`, which is only the base patch's default
|
|
40
|
+
* expression for a `mode` a deployment can also set directly.
|
|
41
|
+
* @param sharing - the mounted backend's disclosure, or `undefined` when no backend is mounted.
|
|
42
|
+
* @returns the line to report, or `undefined` when the seam does dispatch.
|
|
43
|
+
*/
|
|
44
|
+
export function telemetrySeamNotice(sharing) {
|
|
45
|
+
const consequence = 'nothing dispatches the session-telemetry/record waterfall and this plugin\'s telemetry'
|
|
46
|
+
+ ' redaction never runs. Nothing is exported in this state, so this is not a leak — it means the redaction'
|
|
47
|
+
+ ' rules are unverified, and they begin running the moment telemetry is turned on. Informational only: the'
|
|
48
|
+
+ ' plugin\'s other seams are unaffected.';
|
|
49
|
+
switch (sharing) {
|
|
50
|
+
case 'disabled':
|
|
51
|
+
return 'dsh-dlp: telemetryRedaction is enabled, but the mounted session-telemetry backend reports sharing'
|
|
52
|
+
+ ` "disabled", so ${consequence}`;
|
|
53
|
+
case undefined:
|
|
54
|
+
return `dsh-dlp: telemetryRedaction is enabled, but no session-telemetry backend is mounted, so ${consequence}`;
|
|
55
|
+
default:
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
30
59
|
/** Attribute keys whose values are filesystem paths rather than payload text. */
|
|
31
60
|
const PATH_ATTRIBUTES = ['session.cwd'];
|
|
32
61
|
/** Rule identity recorded when a workspace path is replaced. */
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutralising remote markdown images in assistant output, on the `llm/stream`
|
|
3
|
+
* waterfall.
|
|
4
|
+
*
|
|
5
|
+
* The web UI renders any absolute `http:`/`https:` markdown image a model
|
|
6
|
+
* emits as a real `<img src>`, and the harness sets no Content-Security-Policy,
|
|
7
|
+
* so the fetch happens in the user's browser where no host-side listener can
|
|
8
|
+
* see it. This module rewrites the destination out of the assistant's text
|
|
9
|
+
* before it becomes an `assistant/chunk` or `assistant/message` session event,
|
|
10
|
+
* so the log and the rendered answer stay in agreement.
|
|
11
|
+
*
|
|
12
|
+
* Two properties this module exists to hold:
|
|
13
|
+
*
|
|
14
|
+
* - **A destination split across chunks is still caught.** The mock and real
|
|
15
|
+
* adapters both emit text in small deltas, so `` is
|
|
16
|
+
* routinely spread over several of them and the browser renders the
|
|
17
|
+
* accumulation. Text that could still be the start of an image is held back
|
|
18
|
+
* until it either completes or exceeds {@link MAX_HELD_CHARACTERS}.
|
|
19
|
+
* - **Only the destination is replaced.** The alt text survives, so the
|
|
20
|
+
* sentence the model wrote still reads, and the renderer's own
|
|
21
|
+
* non-absolute-URL arm shows that alt text instead of fetching anything.
|
|
22
|
+
*
|
|
23
|
+
* This does not close the channel. It matches inline image syntax only:
|
|
24
|
+
* reference-style images, an alt text carrying a `]`, and any destination form
|
|
25
|
+
* the pattern does not model still reach the renderer. Raw HTML needs no
|
|
26
|
+
* handling — the renderer keeps it as literal text and no HTML enters the DOM
|
|
27
|
+
* (`packages/client/ui-primitives/src/markdown/render.tsx:261-263`). The
|
|
28
|
+
* upstream fix is one `img-src` directive.
|
|
29
|
+
* @module dsh-dlp/images
|
|
30
|
+
*/
|
|
31
|
+
import type { StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
32
|
+
/**
|
|
33
|
+
* Destination substituted for a remote image URL.
|
|
34
|
+
*
|
|
35
|
+
* It is deliberately not a URL: `new URL()` throws on it, which is the
|
|
36
|
+
* renderer's own "not an absolute destination" arm, and that arm renders the
|
|
37
|
+
* alt text as a `<span>` instead of emitting an `<img>`.
|
|
38
|
+
*/
|
|
39
|
+
export declare const BLOCKED_IMAGE_DESTINATION = "dsh-dlp-blocked-remote-image";
|
|
40
|
+
/**
|
|
41
|
+
* Longest suffix held back waiting for an image to complete.
|
|
42
|
+
*
|
|
43
|
+
* Held text is text the user cannot see yet, so the wait is bounded: past this
|
|
44
|
+
* many characters the suffix is emitted as it stands and a destination that
|
|
45
|
+
* completes later is caught only by the assembled block. A protocol bound on
|
|
46
|
+
* this module's own buffering, not a deployment choice.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MAX_HELD_CHARACTERS = 4096;
|
|
49
|
+
/** One string after its remote image destinations were replaced. */
|
|
50
|
+
export interface NeutralizedText {
|
|
51
|
+
readonly text: string;
|
|
52
|
+
/** Hostnames of the replaced destinations, in match order; never the full URL. */
|
|
53
|
+
readonly hosts: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Replace every absolute HTTP(S) inline image destination in one string.
|
|
57
|
+
* @param text - assistant text, whole or partial.
|
|
58
|
+
* @returns the rewritten text and the hosts whose destinations were replaced.
|
|
59
|
+
*/
|
|
60
|
+
export declare function neutralizeRemoteImages(text: string): NeutralizedText;
|
|
61
|
+
/**
|
|
62
|
+
* Where the held suffix of a partially streamed string starts.
|
|
63
|
+
* @param text - everything accumulated for one block and not yet emitted.
|
|
64
|
+
* @returns the offset to emit up to; the string's length when nothing is held.
|
|
65
|
+
*/
|
|
66
|
+
export declare function heldSuffixStart(text: string): number;
|
|
67
|
+
/**
|
|
68
|
+
* Wrap one model stream, replacing remote image destinations in its text.
|
|
69
|
+
*
|
|
70
|
+
* Text deltas are rewritten as they pass, with a possible image start held
|
|
71
|
+
* back until it resolves, and the assembled block on `block-end` — which is
|
|
72
|
+
* what the agent loop turns into the assistant message — is rewritten too. A
|
|
73
|
+
* held suffix is always flushed as a delta before the block closes and before
|
|
74
|
+
* the terminal finish, so no text is lost and the emitted chunks still satisfy
|
|
75
|
+
* the stream grammar.
|
|
76
|
+
* @param source - the stream from the rest of the waterfall.
|
|
77
|
+
* @param onNeutralized - notified once per host per text block.
|
|
78
|
+
* @returns the rewritten stream.
|
|
79
|
+
*/
|
|
80
|
+
export declare function neutralizeImageStream(source: AsyncIterable<StreamChunk>, onNeutralized: (host: string) => void): AsyncIterable<StreamChunk>;
|
|
81
|
+
//# sourceMappingURL=images.d.ts.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
15
15
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
16
16
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
17
|
+
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
|
18
|
+
* assistant output, before the text becomes a session event.
|
|
19
|
+
*
|
|
20
|
+
* Three of those registrations mitigate defects in the harness rather than in
|
|
21
|
+
* a deployment's own configuration: the missing Content-Security-Policy behind
|
|
22
|
+
* (5), the mutable execution object behind the guard's mutation check, and the
|
|
23
|
+
* silently inert telemetry seam behind the notice reported at mount. Each one
|
|
24
|
+
* is partial, none closes its channel, and README.md says so beside the
|
|
25
|
+
* feature.
|
|
17
26
|
*
|
|
18
27
|
* This plugin is not a containment boundary. It runs in-process at the agent's
|
|
19
28
|
* own uid; anything the agent can execute can read the same files the guard
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecting a tool call that was rewritten after it was logged.
|
|
3
|
+
*
|
|
4
|
+
* The registry deep-freezes `exec.arguments` but does not freeze the execution
|
|
5
|
+
* object until results are notified, so a `tools/pre-execute` listener can
|
|
6
|
+
* reassign `exec.arguments` or `exec.name` — and reassigning `exec.name`
|
|
7
|
+
* changes which tool body runs. The agent loop appended `tool/call` from the
|
|
8
|
+
* model's own response block before the waterfall ran, so nothing in the
|
|
9
|
+
* session log records the change: the durable record then describes a
|
|
10
|
+
* different call than the one about to execute.
|
|
11
|
+
*
|
|
12
|
+
* This module snapshots the call as early in the waterfall as it can and
|
|
13
|
+
* compares in the guard, which runs after the whole waterfall and cannot be
|
|
14
|
+
* out-ordered. It is detection, not prevention: preventing the rewrite would
|
|
15
|
+
* mean freezing an object this plugin does not own, and the snapshot itself is
|
|
16
|
+
* best-effort — a later `{ prepend: true }` registration runs ahead of ours and
|
|
17
|
+
* would be snapshotted after its own rewrite.
|
|
18
|
+
* @module dsh-dlp/mutation
|
|
19
|
+
*/
|
|
20
|
+
import type { ToolExecution } from '@deepseek-ai/dsh-tools';
|
|
21
|
+
import type { SpanHasher } from './redaction.ts';
|
|
22
|
+
/** The parts of an execution this module compares. */
|
|
23
|
+
type Comparable = Pick<ToolExecution, 'name' | 'arguments'>;
|
|
24
|
+
/** One rewritten field. */
|
|
25
|
+
export type MutatedField = 'name' | 'arguments';
|
|
26
|
+
/** A call whose identity changed between the snapshot and the guard. */
|
|
27
|
+
export interface ExecutionMutation {
|
|
28
|
+
/** Which fields differ, in a stable order. */
|
|
29
|
+
readonly fields: readonly MutatedField[];
|
|
30
|
+
/** The tool name the session log recorded. */
|
|
31
|
+
readonly originalTool: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Render a JSON value with object keys in a fixed order, so two equal argument
|
|
35
|
+
* sets hash equally whatever order a listener rebuilt them in.
|
|
36
|
+
* @param value - the argument value; JSON-serializable by the registry's own snapshot step.
|
|
37
|
+
* @returns a canonical string for hashing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function canonicalJson(value: unknown): string;
|
|
40
|
+
/**
|
|
41
|
+
* Remembers what each pending call looked like before the rest of the
|
|
42
|
+
* `tools/pre-execute` waterfall ran.
|
|
43
|
+
*
|
|
44
|
+
* Keyed by the execution object's identity in a `WeakMap`, the way the
|
|
45
|
+
* registry keys its own per-execution state, so the entry is found again in
|
|
46
|
+
* the guard and released with the execution.
|
|
47
|
+
*/
|
|
48
|
+
export declare class ExecutionSnapshots {
|
|
49
|
+
#private;
|
|
50
|
+
/**
|
|
51
|
+
* @param hasher - mints the keyed digest of the arguments; the values themselves are never stored.
|
|
52
|
+
*/
|
|
53
|
+
constructor(hasher: SpanHasher);
|
|
54
|
+
/**
|
|
55
|
+
* Snapshot one pending call.
|
|
56
|
+
* @param exec - the execution as the earliest listener sees it.
|
|
57
|
+
*/
|
|
58
|
+
record(exec: Comparable): void;
|
|
59
|
+
/**
|
|
60
|
+
* Compare one call against its snapshot.
|
|
61
|
+
*
|
|
62
|
+
* A call with no snapshot is not a finding: the listener may never have run
|
|
63
|
+
* for it, and reporting absence as mutation would deny calls this plugin
|
|
64
|
+
* simply did not observe.
|
|
65
|
+
* @param exec - the execution as the guard stage sees it.
|
|
66
|
+
* @returns what changed, or `undefined` when nothing did.
|
|
67
|
+
*/
|
|
68
|
+
detect(exec: Comparable): ExecutionMutation | undefined;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Denial text for a rewritten call.
|
|
72
|
+
*
|
|
73
|
+
* Both tool names are named: a tool name is already in the session log and in
|
|
74
|
+
* every other denial this plugin writes, and naming them is the whole point —
|
|
75
|
+
* the operator needs to know which call the log describes and which one was
|
|
76
|
+
* about to run. No argument value appears.
|
|
77
|
+
* @param exec - the call as the guard sees it, after the rewrite.
|
|
78
|
+
* @param mutation - the fields that changed and the recorded tool name.
|
|
79
|
+
* @returns the model-facing reason.
|
|
80
|
+
*/
|
|
81
|
+
export declare function mutationReason(exec: Comparable, mutation: ExecutionMutation): string;
|
|
82
|
+
export {};
|
|
83
|
+
//# sourceMappingURL=mutation.d.ts.map
|
package/lib/types/policy.d.ts
CHANGED
|
@@ -33,12 +33,14 @@ export interface Config {
|
|
|
33
33
|
resultRedaction: boolean;
|
|
34
34
|
/** Whether `session-telemetry/record` redaction runs. */
|
|
35
35
|
telemetryRedaction: boolean;
|
|
36
|
+
/** Whether remote markdown image destinations are neutralised in assistant output. */
|
|
37
|
+
remoteImageNeutralization: boolean;
|
|
36
38
|
/** Whether telemetry's `session.cwd` attribute is replaced with a keyed hash. */
|
|
37
39
|
redactTelemetryWorkspacePaths: boolean;
|
|
38
40
|
}
|
|
39
41
|
export declare const Config: z<Config>;
|
|
40
42
|
/** Config toggles a repo-local policy may switch on, and never off. */
|
|
41
|
-
declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "redactTelemetryWorkspacePaths"];
|
|
43
|
+
declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths"];
|
|
42
44
|
/** One toggle name a repo-local policy may name in `enable`. */
|
|
43
45
|
export type EnableableToggle = typeof ENABLEABLE[number];
|
|
44
46
|
/** Payload version this package writes and accepts for repo-local policy files. */
|
|
@@ -59,6 +61,7 @@ export interface ResolvedPolicy {
|
|
|
59
61
|
readonly breadthTier: boolean;
|
|
60
62
|
readonly resultRedaction: boolean;
|
|
61
63
|
readonly telemetryRedaction: boolean;
|
|
64
|
+
readonly remoteImageNeutralization: boolean;
|
|
62
65
|
readonly redactTelemetryWorkspacePaths: boolean;
|
|
63
66
|
}
|
|
64
67
|
/** Thrown when a policy file is malformed or attempts to loosen the policy. */
|
package/lib/types/sink.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare function newDecisionId(): DecisionId;
|
|
|
28
28
|
/** Payload version carried inside every record this plugin writes. */
|
|
29
29
|
export declare const RECORD_VERSION = 1;
|
|
30
30
|
/** What produced one audit record. */
|
|
31
|
-
export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'result-redaction' | 'telemetry-redaction' | 'audit-failure';
|
|
31
|
+
export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
|
|
32
32
|
/** One durable record. Never carries matched secret text. */
|
|
33
33
|
export interface AuditRecord {
|
|
34
34
|
readonly v: number;
|
|
@@ -62,6 +62,17 @@ export interface AuditRecord {
|
|
|
62
62
|
readonly unicode?: Readonly<Record<string, number>>;
|
|
63
63
|
/** Telemetry record channel, for `telemetry-redaction`. */
|
|
64
64
|
readonly channel?: string;
|
|
65
|
+
/** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
|
|
66
|
+
readonly mutatedFields?: readonly string[];
|
|
67
|
+
/** Tool name the session log recorded, when a rewrite changed it. */
|
|
68
|
+
readonly originalTool?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Hostname of a neutralised remote image destination, for
|
|
71
|
+
* `assistant-image-neutralized`. The hostname only: a path and a query
|
|
72
|
+
* string are where an exfiltration payload rides, and this file must not
|
|
73
|
+
* carry it.
|
|
74
|
+
*/
|
|
75
|
+
readonly host?: string;
|
|
65
76
|
}
|
|
66
77
|
/** Append-only JSONL sink for this plugin's decisions. */
|
|
67
78
|
export declare class AuditSink {
|
package/lib/types/telemetry.d.ts
CHANGED
|
@@ -25,9 +25,24 @@
|
|
|
25
25
|
* workspace path.
|
|
26
26
|
* @module dsh-dlp/telemetry
|
|
27
27
|
*/
|
|
28
|
-
import type { SessionTelemetryRecord } from '@deepseek-ai/dsh-session-telemetry';
|
|
28
|
+
import type { SessionTelemetryRecord, SessionTelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry';
|
|
29
29
|
import type { ResolvedPolicy } from './policy.ts';
|
|
30
30
|
import { type RedactedSpan, type SpanHasher } from './redaction.ts';
|
|
31
|
+
/**
|
|
32
|
+
* What to tell the operator when the redaction seam will never dispatch.
|
|
33
|
+
*
|
|
34
|
+
* A `session-telemetry/record` listener mounts successfully and never runs
|
|
35
|
+
* unless a backend built a coordinator, and the shipped default builds none:
|
|
36
|
+
* the mode is `DISABLED`, so nothing is exported and nothing is dispatched.
|
|
37
|
+
* That is the safe posture, not a leak — but an operator who mounts a redactor
|
|
38
|
+
* under it sees every signal of success and has verified nothing. The
|
|
39
|
+
* backend's own `sharing` disclosure is the resolved answer, so this never
|
|
40
|
+
* guesses at `DSH_TELEMETRY_MODE`, which is only the base patch's default
|
|
41
|
+
* expression for a `mode` a deployment can also set directly.
|
|
42
|
+
* @param sharing - the mounted backend's disclosure, or `undefined` when no backend is mounted.
|
|
43
|
+
* @returns the line to report, or `undefined` when the seam does dispatch.
|
|
44
|
+
*/
|
|
45
|
+
export declare function telemetrySeamNotice(sharing: SessionTelemetrySharingStatus | undefined): string | undefined;
|
|
31
46
|
/** Rule identity recorded when a workspace path is replaced. */
|
|
32
47
|
export declare const WORKSPACE_PATH_RULE = "dsh-dlp/telemetry-workspace-path";
|
|
33
48
|
/** One redacted telemetry record and what was replaced in it. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dlp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Data-loss-prevention plugin for DeepSeek Harness: a non-configurable tool guard floor, tool-result redaction, and fail-closed telemetry redaction",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ivan Tyshchenko <nsof@protonmail.com>",
|