dsh-dlp 0.7.0 → 0.8.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
@@ -48,16 +48,18 @@ Three limits worth knowing before you rely on it:
48
48
 
49
49
  - **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
50
50
  registered ahead of ours. `ctx.tools.guard()` is order-independent only because it has no allow
51
- arm.
51
+ arm. Result redaction registers with `{ prepend: true }` so it gets the last word over
52
+ listeners already registered — but a listener registering after it with the same option lands
53
+ ahead of it again.
52
54
  - **The shell-command arm is advisory pattern-matching.** It tests the whole command line and
53
55
  each of its tokens, so a credential path left *spelled* in the command is caught whatever
54
56
  program would open it: `python3 -c "open('~/.ssh/id_rsa')"` is denied. Changing the spelling
55
57
  defeats it — one glob character, quote-splitting, `find -exec`, a substitution that assembles
56
58
  the path from pieces, a base64 round-trip, each verified. **Do not count this arm as a
57
59
  control.**
58
- - **Detection is pattern-based.** No entropy rule (measured, not assumed: at a false-positive-free
59
- threshold the miss rate is 100% below 22 characters). Encoded forms pass. A homoglyph defeats
60
- every rule in this package.
60
+ - **Detection is pattern-based.** No entropy rule (measured, not assumed: the lowest
61
+ false-positive-free threshold cannot flag anything shorter than 64–66 characters). Encoded
62
+ forms pass. A homoglyph defeats every rule in this package.
61
63
 
62
64
  [The full list of limits →](https://charlotten7.github.io/dsh-dlp/)
63
65
 
@@ -72,8 +74,10 @@ dsh plugin --profile <name> add dsh-dlp
72
74
  dsh --profile <name> --dump-config # the dsh-dlp row should appear
73
75
  ```
74
76
 
75
- Any harness from `0.1.0-rc.6` onwards in the `0.1.x` line works: the peer ranges accept it and CI
76
- runs the end-to-end suite against every published rc in that range.
77
+ Any harness from `0.1.0-rc.6` onwards in the `0.1.x` line works, prereleases included. CI drives
78
+ the end-to-end suite against every published rc the peer ranges admit — `0.1.0-rc.6`, `rc.7`,
79
+ `rc.8`, `0.1.1-rc.1`, `0.1.1-rc.2` — and typechecks and builds against the `0.1.2` prerelease
80
+ line without running it end to end.
77
81
 
78
82
  Pin `@deepseek-ai/dsh-headless` explicitly — its npm `latest` tag still points at `0.0.1-rc.1`.
79
83
  The package ships a `cordis.patch.yml` bundle layer, so listing it in `dsh.profile.bundles` mounts
package/lib/cli.js CHANGED
@@ -74,6 +74,7 @@ export function parseRecord(line) {
74
74
  return undefined;
75
75
  const tool = stringField(record, 'tool');
76
76
  const sessionId = stringField(record, 'sessionId');
77
+ const askUnreachable = stringField(record, 'askUnreachable');
77
78
  return {
78
79
  time,
79
80
  kind,
@@ -81,6 +82,7 @@ export function parseRecord(line) {
81
82
  ...sessionId === undefined ? {} : { sessionId },
82
83
  ruleIds: ruleIdsOf(record),
83
84
  unicode: unicodeOf(record),
85
+ ...askUnreachable === undefined ? {} : { askUnreachable },
84
86
  };
85
87
  }
86
88
  /** Text printed for `--help` and alongside a usage error. */
@@ -255,12 +257,17 @@ export function formatReport(records, unreadable, options) {
255
257
  lines.push(` ${unreadable} line(s) were not readable as records`);
256
258
  if (selected.length === 0)
257
259
  return lines;
258
- lines.push(...section('by decision', tally(selected, record => [record.kind])), ...section('by rule', tally(selected, record => record.ruleIds)), ...section('by tool', tally(selected, record => record.tool === undefined ? [] : [record.tool])), ...section('results carrying invisible characters', tally(selected, record => Object.keys(record.unicode))));
260
+ lines.push(...section('by decision', tally(selected, record => [record.kind])), ...section('by rule', tally(selected, record => record.ruleIds)), ...section('by tool', tally(selected, record => record.tool === undefined ? [] : [record.tool])), ...section('results carrying invisible characters', tally(selected, record => Object.keys(record.unicode))),
261
+ // The state behind an abstention, which nothing else in the report shows.
262
+ // An abstention is the one outcome where a documented prompt did not
263
+ // happen and the call ran anyway, so the operator needs the state by name.
264
+ ...section('asks that reached nobody', tally(selected, record => record.askUnreachable === undefined ? [] : [record.askUnreachable])));
259
265
  const recent = [...selected].sort((left, right) => right.time - left.time).slice(0, RECENT_LIMIT);
260
266
  lines.push('', `most recent ${recent.length}`);
261
267
  for (const record of recent) {
262
268
  const rules = record.ruleIds.length === 0 ? '-' : record.ruleIds.join(', ');
263
- lines.push(` ${new Date(record.time).toISOString()} ${record.kind} ${record.tool ?? '-'} ${rules}`);
269
+ const abstention = record.askUnreachable === undefined ? '' : ` no prompt: ${record.askUnreachable}`;
270
+ lines.push(` ${new Date(record.time).toISOString()} ${record.kind} ${record.tool ?? '-'} ${rules}${abstention}`);
264
271
  }
265
272
  return lines;
266
273
  }
package/lib/detectors.js CHANGED
@@ -45,7 +45,23 @@ export const DENY_SEVERITY = 'high';
45
45
  export const SYNC_RULES = [
46
46
  { id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
47
47
  { id: 'dsh-dlp/aws-secret-access-key', version: 1, severity: 'critical', pattern: /\baws_secret_access_key\b\s*[=:]\s*["']?[A-Za-z0-9/+=]{40}["']?/gi },
48
- { id: 'dsh-dlp/github-token', version: 1, severity: 'critical', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,251}|github_pat_[A-Za-z0-9_]{22,251})\b/g },
48
+ // Three formats, and the stateless one is first because the opaque
49
+ // alternative would otherwise claim its `ghs_` prefix and stop at the app
50
+ // id. GitHub's 2026-04-24 changelog gives the shape as `ghs_APPID_JWT`,
51
+ // "~520 characters" carrying two dots, rolled out from 2026-04-27 to late
52
+ // June; the opaque 40-character format stays because existing tokens
53
+ // "continue to work until they expire". GitHub's own suggested
54
+ // `ghs_[A-Za-z0-9.\-_]{36,}` is deliberately not what this uses: on a floor
55
+ // rule a false positive is a denial, and that class matches an ordinary
56
+ // dotted file name — `ghs_report-2026-04-24.summary-eu-west-1.json` is 40
57
+ // characters past the prefix. Anchoring on the JWT the changelog describes
58
+ // costs nothing a real token has.
59
+ {
60
+ id: 'dsh-dlp/github-token',
61
+ version: 2,
62
+ severity: 'critical',
63
+ pattern: /\b(?:ghs_[A-Za-z0-9]{1,32}_eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])|gh[pousr]_[A-Za-z0-9]{36,251}\b|github_pat_[A-Za-z0-9_]{22,251}\b)/g,
64
+ },
49
65
  { id: 'dsh-dlp/slack-token', version: 1, severity: 'critical', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
50
66
  { id: 'dsh-dlp/stripe-secret-key', version: 1, severity: 'critical', pattern: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/g },
51
67
  { id: 'dsh-dlp/anthropic-api-key', version: 1, severity: 'critical', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
@@ -89,7 +105,19 @@ export const SYNC_RULES = [
89
105
  { id: 'dsh-dlp/cloudflare-api-token', version: 1, severity: 'critical', pattern: /\bcf(?:ut|at|k)_[A-Za-z0-9_-]{40,}/g },
90
106
  { id: 'dsh-dlp/notion-token', version: 1, severity: 'critical', pattern: /\bntn_[A-Za-z0-9]{40,}/g },
91
107
  { id: 'dsh-dlp/private-key-block', version: 1, severity: 'critical', pattern: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g },
92
- { id: 'dsh-dlp/json-web-token', version: 1, severity: 'high', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
108
+ // Anchored on the base64url alphabet rather than on word characters. `\b`
109
+ // was wrong in both directions: `_` is a word character, so a JWT behind any
110
+ // `prefix_` was invisible, and a signature's trailing `-` was trimmed off the
111
+ // reported span. A letter or digit before `eyJ` still refuses, which is what
112
+ // keeps a match from starting in the middle of a longer token; `_` and `-`
113
+ // are base64url characters but are separators far more often, and the two
114
+ // literal dots this pattern requires are in no base64url run.
115
+ {
116
+ id: 'dsh-dlp/json-web-token',
117
+ version: 2,
118
+ severity: 'high',
119
+ pattern: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])/g,
120
+ },
93
121
  { id: 'dsh-dlp/credential-url', version: 1, severity: 'high', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]+@[^\s/]+/gi },
94
122
  // Webhook URLs are bearer credentials whose path segment is the secret. They
95
123
  // are in tier 1 rather than left to secretlint because the telemetry seam is
package/lib/index.js CHANGED
@@ -17,6 +17,8 @@
17
17
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
18
18
  * session event is appended, so the durable log records the redacted copy;
19
19
  * a result that cannot be cleaned is withheld rather than accepted.
20
+ * Prepended, so it redacts what the rest of the waterfall returned; a
21
+ * listener registering later with the same option still runs ahead of it.
20
22
  * 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
21
23
  * reaching tier 1 only because the waterfall is synchronous.
22
24
  * 5. `llm/stream` — neutralising remote markdown image destinations in
@@ -328,6 +330,11 @@ export function apply(ctx, config) {
328
330
  });
329
331
  }
330
332
  if (policy.resultRedaction) {
333
+ // Prepended for the same reason as the snapshot listener above, and with
334
+ // the same limit: listeners run outermost-first, so registering ahead of
335
+ // the chain is what lets this one redact the decision the rest of the
336
+ // waterfall settled on rather than have its own replaced afterwards. A
337
+ // listener registered later with the same option still lands ahead of it.
331
338
  ctx.on('tools/post-execute', async (exec, result, next) => {
332
339
  // The live definition, so a schema the deployment's own tool declares is
333
340
  // read from the registry rather than assumed. `exec.agent` is the scope
@@ -355,7 +362,7 @@ export function apply(ctx, config) {
355
362
  });
356
363
  }
357
364
  return redacted.decision;
358
- });
365
+ }, { prepend: true });
359
366
  }
360
367
  if (policy.remoteImageNeutralization) {
361
368
  ctx.on('llm/stream', (options, next) => neutralizeImageStream(next(), (host) => {
package/lib/redaction.js CHANGED
@@ -166,10 +166,37 @@ function pointerSegment(segment) {
166
166
  return segment.replace(/~/g, '~0').replace(/\//g, '~1');
167
167
  }
168
168
  /**
169
- * Redact every string inside a JSON value, at any depth.
169
+ * Replace every detected region of every string inside one structure.
170
170
  *
171
171
  * Object *keys* are left alone: a key is structure, not payload, and renaming
172
- * one would break the owning tool's `output.schema` on re-validation.
172
+ * one would break the owning tool's `output.schema` on re-validation. The
173
+ * structure is otherwise rebuilt unchanged — only string leaves differ — which
174
+ * is what lets the callers re-type the result as what they handed in.
175
+ * @param node - the structure to walk.
176
+ * @param path - JSON pointer of this node.
177
+ * @param scan - synchronous detector applied to each string.
178
+ * @param hasher - mints each span's keyed hash.
179
+ * @param spans - collects every span replaced anywhere in the walk.
180
+ * @returns the same structure with each detected region replaced.
181
+ */
182
+ function redactNode(node, path, scan, hasher, spans) {
183
+ if (typeof node === 'string') {
184
+ const redacted = redactText(node, scan(node), hasher, path);
185
+ if (redacted.spans.length === 0)
186
+ return node;
187
+ spans.push(...redacted.spans);
188
+ return redacted.text;
189
+ }
190
+ if (Array.isArray(node)) {
191
+ return node.map((item, index) => redactNode(item, `${path}/${index}`, scan, hasher, spans));
192
+ }
193
+ if (typeof node === 'object' && node !== null) {
194
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, redactNode(item, `${path}/${pointerSegment(key)}`, scan, hasher, spans)]));
195
+ }
196
+ return node;
197
+ }
198
+ /**
199
+ * Redact every string inside a JSON value, at any depth.
173
200
  * @param value - the structure to redact.
174
201
  * @param scan - synchronous detector applied to each string.
175
202
  * @param hasher - mints each span's keyed hash.
@@ -177,26 +204,10 @@ function pointerSegment(segment) {
177
204
  */
178
205
  export function redactJson(value, scan, hasher) {
179
206
  const spans = [];
180
- let changed = false;
181
- const walk = (node, path) => {
182
- if (typeof node === 'string') {
183
- const redacted = redactText(node, scan(node), hasher, path);
184
- if (redacted.spans.length === 0)
185
- return node;
186
- changed = true;
187
- spans.push(...redacted.spans);
188
- return redacted.text;
189
- }
190
- if (Array.isArray(node)) {
191
- return node.map((item, index) => walk(item, `${path}/${index}`));
192
- }
193
- if (typeof node === 'object' && node !== null) {
194
- return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, walk(item, `${path}/${pointerSegment(key)}`)]));
195
- }
196
- return node;
197
- };
198
- const result = walk(value, '');
199
- return { value: result, spans, changed };
207
+ // `redactNode` replaces strings with strings and rebuilds everything else as
208
+ // it found it, so what comes back is the same JSON shape it was handed.
209
+ const result = redactNode(value, '', scan, hasher, spans);
210
+ return { value: result, spans, changed: spans.length > 0 };
200
211
  }
201
212
  /**
202
213
  * Redact the text blocks of one model-facing content array. Non-text blocks
@@ -205,15 +216,16 @@ export function redactJson(value, scan, hasher) {
205
216
  * @param blocks - the content blocks to redact.
206
217
  * @param scan - synchronous detector applied to each block's text.
207
218
  * @param hasher - mints each span's keyed hash.
219
+ * @param pathPrefix - JSON pointer the recorded pointers hang off, for blocks that are not the decision's own.
208
220
  * @returns the redacted blocks, the spans replaced, and whether anything changed.
209
221
  */
210
- export function redactContent(blocks, scan, hasher) {
222
+ export function redactContent(blocks, scan, hasher, pathPrefix = '') {
211
223
  const spans = [];
212
224
  let changed = false;
213
225
  const content = blocks.map((block, index) => {
214
226
  if (block.type !== 'text')
215
227
  return block;
216
- const redacted = redactText(block.text, scan(block.text), hasher, `/${index}/text`);
228
+ const redacted = redactText(block.text, scan(block.text), hasher, `${pathPrefix}/${index}/text`);
217
229
  if (redacted.spans.length === 0)
218
230
  return block;
219
231
  changed = true;
@@ -222,3 +234,50 @@ export function redactContent(blocks, scan, hasher) {
222
234
  });
223
235
  return { content, spans, changed };
224
236
  }
237
+ /**
238
+ * Source fields that say what a message *is* rather than what it says. `kind`
239
+ * selects the source arm, `form` selects the fields that arm carries, and
240
+ * `plugin` names the producer; replacing one of those would change the
241
+ * message's identity rather than redact its text.
242
+ */
243
+ const MESSAGE_SOURCE_STRUCTURE = new Set(['kind', 'form', 'plugin']);
244
+ /**
245
+ * Redact the text of the `UserMessage`s a tool decision carries.
246
+ *
247
+ * A message's payload is its model-facing `content` blocks and whatever text
248
+ * its `source` records: a `snapshot` source repeats the block text in
249
+ * `sections[].text`, a `notice` source repeats its opening in `summary`, and
250
+ * `MessageSourceMap` is merge-extensible, so a plugin's own source kind may
251
+ * carry text of its own. All of it is appended to the session log with the
252
+ * message, so redacting the blocks alone would leave a copy behind. Both
253
+ * halves are walked with the same scan and the same hasher, and identical text
254
+ * yields an identical placeholder, so the copies stay in step.
255
+ *
256
+ * `id` and `role` are left alone: the inbox addresses a message by its id.
257
+ * A message with nothing to replace is returned as the object it arrived as.
258
+ * @param messages - the contexts to redact, in the order they are attached.
259
+ * @param scan - synchronous detector applied to each string.
260
+ * @param hasher - mints each span's keyed hash.
261
+ * @param pathPrefix - JSON pointer the recorded pointers hang off.
262
+ * @returns the redacted messages, the spans replaced, and whether anything changed.
263
+ */
264
+ export function redactUserMessages(messages, scan, hasher, pathPrefix) {
265
+ const spans = [];
266
+ const redacted = messages.map((message, index) => {
267
+ const path = `${pathPrefix}/${index}`;
268
+ const before = spans.length;
269
+ const content = redactContent(message.content, scan, hasher, `${path}/content`);
270
+ spans.push(...content.spans);
271
+ // Rebuilt entry by entry so the discriminants pass through untouched;
272
+ // `redactNode` returns each remaining field as the shape it was given, so
273
+ // the result is the same source with redacted text.
274
+ const source = Object.fromEntries(Object.entries(message.source).map(([key, value]) => [
275
+ key,
276
+ MESSAGE_SOURCE_STRUCTURE.has(key)
277
+ ? value
278
+ : redactNode(value, `${path}/source/${pointerSegment(key)}`, scan, hasher, spans),
279
+ ]));
280
+ return spans.length === before ? message : { ...message, content: content.content, source };
281
+ });
282
+ return { messages: redacted, spans, changed: spans.length > 0 };
283
+ }
package/lib/results.js CHANGED
@@ -4,15 +4,18 @@
4
4
  *
5
5
  * Both are best-effort by construction. A `tools/pre-execute` listener
6
6
  * registered ahead of ours can return without calling `next()` and neutralize
7
- * the breadth tier; a `tools/post-execute` listener ahead of ours can replace a
8
- * result after we redacted it. Only `ctx.tools.guard()` is order-independent.
7
+ * the breadth tier. Result redaction registers with `{ prepend: true }`, so it
8
+ * redacts whatever the rest of the waterfall settled on rather than having its
9
+ * own decision replaced afterwards — but `prepend` unshifts, so a listener
10
+ * that registers later with the same option still lands ahead of it. Only
11
+ * `ctx.tools.guard()` is order-independent, and it cannot rewrite a result.
9
12
  * What these seams buy is breadth: they can await, so `@secretlint/core`'s
10
13
  * whole rule set applies here and not in the guard.
11
14
  * @module dsh-dlp/results
12
15
  */
13
16
  import { DENY_SEVERITY, countUnicodeIndicators, scanAll, scanSync, severityRank, } from "./detectors.js";
14
17
  import { isEgressCapable } from "./paths.js";
15
- import { nestedStrings, redactContent, redactJson } from "./redaction.js";
18
+ import { nestedStrings, redactContent, redactJson, redactUserMessages, } from "./redaction.js";
16
19
  import { redactionBreaksSchema } from "./schema.js";
17
20
  /**
18
21
  * Separator the strings of one result are rendered with before the
@@ -76,6 +79,17 @@ async function prepareScan(strings, policy) {
76
79
  function contentStrings(blocks) {
77
80
  return blocks.flatMap(block => block.type === 'text' ? [block.text] : []);
78
81
  }
82
+ /**
83
+ * Text one set of `additionalContexts` carries: the model-facing blocks, plus
84
+ * whatever text the source records beside them — a `snapshot` source repeats
85
+ * the block text in `sections[].text`, a `notice` source its opening in
86
+ * `summary`, and a plugin's own source kind may carry more.
87
+ * @param messages - the contexts attached to a decision or ferried on a result.
88
+ * @returns every string those messages would put in front of the model or into the log.
89
+ */
90
+ function messageStrings(messages) {
91
+ return messages.flatMap(message => [...contentStrings(message.content), ...nestedStrings(message.source)]);
92
+ }
79
93
  /**
80
94
  * Strings the harness keeps in the durable result when the decision does not
81
95
  * replace the canonical value.
@@ -121,8 +135,23 @@ function withheldFeedback(spans) {
121
135
  * persisted surfaces are already clean — a failed result, which has no
122
136
  * value, or a success whose secret exists only in the rendered content.
123
137
  * - `block` is the fallback when neither works: a failed result whose `meta`
124
- * carries a secret, or a value that still scans dirty after redaction.
125
- * Blocking replaces the whole result, which is the only way to drop `meta`.
138
+ * carries a secret, a value that still scans dirty after redaction, or a
139
+ * context the tool body deferred. Blocking replaces the whole result, which
140
+ * is the only way to drop either.
141
+ *
142
+ * `additionalContexts` reach both the model and the log — the loop hands each
143
+ * one to the inbox, which appends an `agent/inbox/spliced` event carrying the
144
+ * whole message — and they arrive from two places that are not equally
145
+ * reachable:
146
+ *
147
+ * - the ones this decision carries, which the returned decision owns, so they
148
+ * are redacted in place. Rewriting them is a rewrite of another listener's
149
+ * data, which is the same thing the value arm already does to a downstream
150
+ * `accept{content}`, and it costs a placeholder rather than a lost result;
151
+ * - the ones the tool body deferred, which the registry concatenates ahead of
152
+ * the decision's own on *every* accept arm. No accept can rewrite or drop
153
+ * them, so a dirty one is withheld. That is `meta`'s case exactly, and it is
154
+ * the one place scanning contexts can cost a successful result.
126
155
  *
127
156
  * Replacing the value is re-validated by the registry against the tool's
128
157
  * `output.schema`, and a schema that pins the redacted string rejects it. That
@@ -142,21 +171,30 @@ function withheldFeedback(spans) {
142
171
  * @returns the decision to return, the spans replaced, and scan completeness.
143
172
  */
144
173
  export async function redactDecision(decision, result, policy, hasher, outputSchema) {
174
+ const attached = decision.additionalContexts ?? [];
145
175
  if (decision.kind === 'block') {
146
- const prepared = await prepareScan(contentStrings(decision.feedback), policy);
176
+ // A block exposes only the blocking decision's own contexts, so the
177
+ // feedback and those are the whole surface this arm can put in front of
178
+ // the model.
179
+ const prepared = await prepareScan([...contentStrings(decision.feedback), ...messageStrings(attached)], policy);
147
180
  const redacted = redactContent(decision.feedback, prepared.scan, hasher);
181
+ const redactedContexts = redactUserMessages(attached, prepared.scan, hasher, '/additionalContexts');
148
182
  return {
149
- decision: redacted.changed ? { ...decision, feedback: redacted.content } : decision,
150
- spans: redacted.spans,
183
+ decision: redacted.changed || redactedContexts.changed
184
+ ? {
185
+ ...decision,
186
+ feedback: redacted.content,
187
+ ...redactedContexts.changed ? { additionalContexts: redactedContexts.messages } : {},
188
+ }
189
+ : decision,
190
+ spans: [...redacted.spans, ...redactedContexts.spans],
151
191
  truncatedScan: prepared.truncated,
152
192
  indicators: prepared.indicators,
153
193
  };
154
194
  }
155
195
  const replacedValue = Object.hasOwn(decision, 'value') ? decision.value : undefined;
156
196
  const replacedContent = Object.hasOwn(decision, 'content') ? decision.content : undefined;
157
- const contexts = decision.additionalContexts === undefined
158
- ? {}
159
- : { additionalContexts: decision.additionalContexts };
197
+ const deferred = result.additionalContexts ?? [];
160
198
  // The value the harness will persist: a downstream replacement when there is
161
199
  // one, otherwise the tool's own. A failed result has no value at all.
162
200
  const value = replacedValue ?? (result.isError ? undefined : result.value);
@@ -164,31 +202,39 @@ export async function redactDecision(decision, result, policy, hasher, outputSch
164
202
  const persisted = replacedValue === undefined
165
203
  ? persistedStrings(result)
166
204
  : [...nestedStrings(replacedValue), ...result.meta === undefined ? [] : nestedStrings(result.meta)];
167
- const prepared = await prepareScan([...persisted, ...visible], policy);
205
+ const prepared = await prepareScan([...persisted, ...visible, ...messageStrings(attached), ...messageStrings(deferred)], policy);
168
206
  const dirty = (strings) => strings.some(text => prepared.scan(text).length > 0);
207
+ const redactedContexts = redactUserMessages(attached, prepared.scan, hasher, '/additionalContexts');
208
+ // A clean set is passed on as the array it arrived as, so a downstream
209
+ // listener's own messages are not rebuilt for nothing.
210
+ const contexts = decision.additionalContexts === undefined
211
+ ? {}
212
+ : { additionalContexts: redactedContexts.changed ? redactedContexts.messages : decision.additionalContexts };
213
+ /** Withhold the result, keeping the redacted contexts the decision brought. */
214
+ const withhold = (spans, truncated) => ({
215
+ decision: { kind: 'block', feedback: withheldFeedback(spans), ...contexts },
216
+ spans: [...spans, ...redactedContexts.spans],
217
+ truncatedScan: truncated,
218
+ indicators: prepared.indicators,
219
+ });
220
+ // Deferred contexts ride every accept arm untouched, so a dirty one settles
221
+ // the decision before any arm is considered.
222
+ const deferredSpans = redactUserMessages(deferred, prepared.scan, hasher, '/result/additionalContexts').spans;
223
+ if (deferredSpans.length > 0)
224
+ return withhold(deferredSpans, prepared.truncated);
169
225
  if (value !== undefined && dirty(nestedStrings(value))) {
170
226
  const redacted = redactJson(value, prepared.scan, hasher);
171
227
  if (redactionBreaksSchema(outputSchema, value, redacted.value)) {
172
- return {
173
- decision: { kind: 'block', feedback: withheldFeedback(redacted.spans) },
174
- spans: redacted.spans,
175
- truncatedScan: prepared.truncated,
176
- indicators: prepared.indicators,
177
- };
228
+ return withhold(redacted.spans, prepared.truncated);
178
229
  }
179
230
  const remaining = nestedStrings(redacted.value);
180
231
  const residual = await prepareScan(remaining, policy);
181
232
  if (remaining.some(text => residual.scan(text).length > 0)) {
182
- return {
183
- decision: { kind: 'block', feedback: withheldFeedback(redacted.spans) },
184
- spans: redacted.spans,
185
- truncatedScan: prepared.truncated || residual.truncated,
186
- indicators: prepared.indicators,
187
- };
233
+ return withhold(redacted.spans, prepared.truncated || residual.truncated);
188
234
  }
189
235
  return {
190
236
  decision: { kind: 'accept', value: redacted.value, ...contexts },
191
- spans: redacted.spans,
237
+ spans: [...redacted.spans, ...redactedContexts.spans],
192
238
  truncatedScan: prepared.truncated,
193
239
  indicators: prepared.indicators,
194
240
  };
@@ -196,22 +242,21 @@ export async function redactDecision(decision, result, policy, hasher, outputSch
196
242
  // The value is clean, so the durable result is clean unless `meta` — which
197
243
  // no accept arm can rewrite — carries something of its own.
198
244
  if (result.meta !== undefined && dirty(nestedStrings(result.meta))) {
199
- const spans = redactJson(result.meta, prepared.scan, hasher).spans;
200
- return {
201
- decision: { kind: 'block', feedback: withheldFeedback(spans) },
202
- spans,
203
- truncatedScan: prepared.truncated,
204
- indicators: prepared.indicators,
205
- };
245
+ return withhold(redactJson(result.meta, prepared.scan, hasher).spans, prepared.truncated);
206
246
  }
207
247
  const blocks = replacedContent ?? result.content;
208
248
  const redacted = redactContent(blocks, prepared.scan, hasher);
209
249
  if (!redacted.changed) {
210
- return { decision, spans: [], truncatedScan: prepared.truncated, indicators: prepared.indicators };
250
+ return {
251
+ decision: redactedContexts.changed ? { ...decision, ...contexts } : decision,
252
+ spans: redactedContexts.spans,
253
+ truncatedScan: prepared.truncated,
254
+ indicators: prepared.indicators,
255
+ };
211
256
  }
212
257
  return {
213
258
  decision: { kind: 'accept', content: redacted.content, ...contexts },
214
- spans: redacted.spans,
259
+ spans: [...redacted.spans, ...redactedContexts.spans],
215
260
  truncatedScan: prepared.truncated,
216
261
  indicators: prepared.indicators,
217
262
  };
package/lib/sink.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * `callId`, and a producer-minted `decisionId`.
15
15
  * @module dsh-dlp/sink
16
16
  */
17
- import { appendFileSync } from 'node:fs';
17
+ import { appendFileSync, chmodSync } from 'node:fs';
18
18
  import { randomUUID } from 'node:crypto';
19
19
  import { stripControlSequences } from "./detectors.js";
20
20
  /**
@@ -26,6 +26,15 @@ export function newDecisionId() {
26
26
  }
27
27
  /** Payload version carried inside every record this plugin writes. */
28
28
  export const RECORD_VERSION = 1;
29
+ /**
30
+ * Mode the sink file is kept at.
31
+ *
32
+ * The records hold rule ids, keyed hashes, tool names and call identity — no
33
+ * matched value ever reaches them — but they are the evidence that a decision
34
+ * happened, so they are readable by the operator's group and never by the
35
+ * world. This is the mode the sibling packages keep their spools at.
36
+ */
37
+ export const AUDIT_MODE = 0o640;
29
38
  /**
30
39
  * One record with every string cleaned of terminal control sequences.
31
40
  *
@@ -68,12 +77,20 @@ export class AuditSink {
68
77
  * evidence, not enforcement, and letting a full disk turn every tool call
69
78
  * into a denial trades a confidentiality control for an availability
70
79
  * outage. A guard that throws would also skip `tools/post-execute` and so
71
- * disable redaction for that call.
80
+ * disable redaction for that call. A failure to hold
81
+ * {@link AUDIT_MODE} is reported on the same terms: the record is written
82
+ * either way, and an operator who cannot see the mode cannot know who else
83
+ * can read the file.
72
84
  * @param record - the decision to record.
73
85
  */
74
86
  write(record) {
75
87
  try {
76
- appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`);
88
+ appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`, { mode: AUDIT_MODE });
89
+ // `appendFileSync`'s `mode` applies only when the call creates the file,
90
+ // and even then the umask masks it, so the mode is forced afterwards.
91
+ // Forcing it on every append also takes back a loosening applied to an
92
+ // existing sink.
93
+ chmodSync(this.#path, AUDIT_MODE);
77
94
  }
78
95
  catch (error) {
79
96
  this.#onFailure(error);
package/lib/telemetry.js CHANGED
@@ -26,7 +26,7 @@
26
26
  * @module dsh-dlp/telemetry
27
27
  */
28
28
  import { scanSync } from "./detectors.js";
29
- import { placeholderFor, redactJson, redactText } from "./redaction.js";
29
+ import { placeholderFor, redactJson, redactText, } from "./redaction.js";
30
30
  /**
31
31
  * What to tell the operator when the redaction seam will never dispatch.
32
32
  *
@@ -23,6 +23,12 @@ export interface ReportRecord {
23
23
  readonly ruleIds: readonly string[];
24
24
  /** Invisible-character runs by rule id. */
25
25
  readonly unicode: Readonly<Record<string, number>>;
26
+ /**
27
+ * Which state left the ask tier with nowhere to ask, on the one kind that
28
+ * records it. The abstention allowed a call the tier would have asked
29
+ * about, so this is the field an operator changes to get the prompt back.
30
+ */
31
+ readonly askUnreachable?: string;
26
32
  }
27
33
  /**
28
34
  * Parse one JSONL line into the fields this command reports on.
@@ -17,6 +17,8 @@
17
17
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
18
18
  * session event is appended, so the durable log records the redacted copy;
19
19
  * a result that cannot be cleaned is withheld rather than accepted.
20
+ * Prepended, so it redacts what the rest of the waterfall returned; a
21
+ * listener registering later with the same option still runs ahead of it.
20
22
  * 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
21
23
  * reaching tier 1 only because the waterfall is synchronous.
22
24
  * 5. `llm/stream` — neutralising remote markdown image destinations in
@@ -14,9 +14,22 @@
14
14
  * installation key, useless to anyone without the key.
15
15
  * @module dsh-dlp/redaction
16
16
  */
17
- import type { ContentBlock } from '@deepseek-ai/dsh-llm';
18
- import type { JsonValue } from '@deepseek-ai/dsh-session';
17
+ import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm';
19
18
  import { type Detection, type Severity } from './detectors.ts';
19
+ /**
20
+ * A value that round-trips through JSON without loss.
21
+ *
22
+ * Declared here rather than imported. Upstream moved this alias out of
23
+ * `@deepseek-ai/dsh-session` and into `@deepseek-ai/dsh-util-values` in
24
+ * `0.1.2-alpha.2`; the new package does not exist in any release the peer
25
+ * ranges also admit, and a TypeScript import cannot name two homes, so either
26
+ * import breaks half the supported range. The alias is structural and carries
27
+ * no runtime, so a local copy is assignable in both directions wherever it
28
+ * meets upstream's — and nothing outside this package is handed one.
29
+ */
30
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
31
+ [key: string]: JsonValue;
32
+ };
20
33
  /** One replaced region, described without disclosing what it held. */
21
34
  export interface RedactedSpan {
22
35
  /** Rule that justified the replacement; the strictest one when spans merged. */
@@ -83,9 +96,6 @@ export declare function redactText(text: string, detections: readonly Detection[
83
96
  export declare function nestedStrings(value: unknown): string[];
84
97
  /**
85
98
  * Redact every string inside a JSON value, at any depth.
86
- *
87
- * Object *keys* are left alone: a key is structure, not payload, and renaming
88
- * one would break the owning tool's `output.schema` on re-validation.
89
99
  * @param value - the structure to redact.
90
100
  * @param scan - synchronous detector applied to each string.
91
101
  * @param hasher - mints each span's keyed hash.
@@ -103,11 +113,37 @@ export declare function redactJson(value: JsonValue, scan: (text: string) => rea
103
113
  * @param blocks - the content blocks to redact.
104
114
  * @param scan - synchronous detector applied to each block's text.
105
115
  * @param hasher - mints each span's keyed hash.
116
+ * @param pathPrefix - JSON pointer the recorded pointers hang off, for blocks that are not the decision's own.
106
117
  * @returns the redacted blocks, the spans replaced, and whether anything changed.
107
118
  */
108
- export declare function redactContent(blocks: readonly ContentBlock[], scan: (text: string) => readonly Detection[], hasher: SpanHasher): {
119
+ export declare function redactContent(blocks: readonly ContentBlock[], scan: (text: string) => readonly Detection[], hasher: SpanHasher, pathPrefix?: string): {
109
120
  content: ContentBlock[];
110
121
  spans: readonly RedactedSpan[];
111
122
  changed: boolean;
112
123
  };
124
+ /**
125
+ * Redact the text of the `UserMessage`s a tool decision carries.
126
+ *
127
+ * A message's payload is its model-facing `content` blocks and whatever text
128
+ * its `source` records: a `snapshot` source repeats the block text in
129
+ * `sections[].text`, a `notice` source repeats its opening in `summary`, and
130
+ * `MessageSourceMap` is merge-extensible, so a plugin's own source kind may
131
+ * carry text of its own. All of it is appended to the session log with the
132
+ * message, so redacting the blocks alone would leave a copy behind. Both
133
+ * halves are walked with the same scan and the same hasher, and identical text
134
+ * yields an identical placeholder, so the copies stay in step.
135
+ *
136
+ * `id` and `role` are left alone: the inbox addresses a message by its id.
137
+ * A message with nothing to replace is returned as the object it arrived as.
138
+ * @param messages - the contexts to redact, in the order they are attached.
139
+ * @param scan - synchronous detector applied to each string.
140
+ * @param hasher - mints each span's keyed hash.
141
+ * @param pathPrefix - JSON pointer the recorded pointers hang off.
142
+ * @returns the redacted messages, the spans replaced, and whether anything changed.
143
+ */
144
+ export declare function redactUserMessages(messages: readonly UserMessage[], scan: (text: string) => readonly Detection[], hasher: SpanHasher, pathPrefix: string): {
145
+ messages: UserMessage[];
146
+ spans: readonly RedactedSpan[];
147
+ changed: boolean;
148
+ };
113
149
  //# sourceMappingURL=redaction.d.ts.map
@@ -4,8 +4,11 @@
4
4
  *
5
5
  * Both are best-effort by construction. A `tools/pre-execute` listener
6
6
  * registered ahead of ours can return without calling `next()` and neutralize
7
- * the breadth tier; a `tools/post-execute` listener ahead of ours can replace a
8
- * result after we redacted it. Only `ctx.tools.guard()` is order-independent.
7
+ * the breadth tier. Result redaction registers with `{ prepend: true }`, so it
8
+ * redacts whatever the rest of the waterfall settled on rather than having its
9
+ * own decision replaced afterwards — but `prepend` unshifts, so a listener
10
+ * that registers later with the same option still lands ahead of it. Only
11
+ * `ctx.tools.guard()` is order-independent, and it cannot rewrite a result.
9
12
  * What these seams buy is breadth: they can await, so `@secretlint/core`'s
10
13
  * whole rule set applies here and not in the guard.
11
14
  * @module dsh-dlp/results
@@ -40,8 +43,23 @@ export interface ResultRedaction {
40
43
  * persisted surfaces are already clean — a failed result, which has no
41
44
  * value, or a success whose secret exists only in the rendered content.
42
45
  * - `block` is the fallback when neither works: a failed result whose `meta`
43
- * carries a secret, or a value that still scans dirty after redaction.
44
- * Blocking replaces the whole result, which is the only way to drop `meta`.
46
+ * carries a secret, a value that still scans dirty after redaction, or a
47
+ * context the tool body deferred. Blocking replaces the whole result, which
48
+ * is the only way to drop either.
49
+ *
50
+ * `additionalContexts` reach both the model and the log — the loop hands each
51
+ * one to the inbox, which appends an `agent/inbox/spliced` event carrying the
52
+ * whole message — and they arrive from two places that are not equally
53
+ * reachable:
54
+ *
55
+ * - the ones this decision carries, which the returned decision owns, so they
56
+ * are redacted in place. Rewriting them is a rewrite of another listener's
57
+ * data, which is the same thing the value arm already does to a downstream
58
+ * `accept{content}`, and it costs a placeholder rather than a lost result;
59
+ * - the ones the tool body deferred, which the registry concatenates ahead of
60
+ * the decision's own on *every* accept arm. No accept can rewrite or drop
61
+ * them, so a dirty one is withheld. That is `meta`'s case exactly, and it is
62
+ * the one place scanning contexts can cost a successful result.
45
63
  *
46
64
  * Replacing the value is re-validated by the registry against the tool's
47
65
  * `output.schema`, and a schema that pins the redacted string rejects it. That
@@ -28,6 +28,15 @@ export type DecisionId = string & {
28
28
  export declare function newDecisionId(): DecisionId;
29
29
  /** Payload version carried inside every record this plugin writes. */
30
30
  export declare const RECORD_VERSION = 1;
31
+ /**
32
+ * Mode the sink file is kept at.
33
+ *
34
+ * The records hold rule ids, keyed hashes, tool names and call identity — no
35
+ * matched value ever reaches them — but they are the evidence that a decision
36
+ * happened, so they are readable by the operator's group and never by the
37
+ * world. This is the mode the sibling packages keep their spools at.
38
+ */
39
+ export declare const AUDIT_MODE = 416;
31
40
  /** What produced one audit record. */
32
41
  export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | 'pre-execute-ask-abstained' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
33
42
  /** One durable record. Never carries matched secret text. */
@@ -103,7 +112,10 @@ export declare class AuditSink {
103
112
  * evidence, not enforcement, and letting a full disk turn every tool call
104
113
  * into a denial trades a confidentiality control for an availability
105
114
  * outage. A guard that throws would also skip `tools/post-execute` and so
106
- * disable redaction for that call.
115
+ * disable redaction for that call. A failure to hold
116
+ * {@link AUDIT_MODE} is reported on the same terms: the record is written
117
+ * either way, and an operator who cannot see the mode cannot know who else
118
+ * can read the file.
107
119
  * @param record - the decision to record.
108
120
  */
109
121
  write(record: AuditRecord): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.7.0",
3
+ "version": "0.8.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>",
@@ -50,11 +50,11 @@
50
50
  }
51
51
  },
52
52
  "peerDependencies": {
53
- "@deepseek-ai/cordis": "4.0.1",
54
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
55
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
56
- "@deepseek-ai/dsh-session-telemetry": "^0.1.0-rc.6",
57
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
53
+ "@deepseek-ai/cordis": "^4.0.1",
54
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
55
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
56
+ "@deepseek-ai/dsh-session-telemetry": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
57
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0"
58
58
  },
59
59
  "dependencies": {
60
60
  "@deepseek-ai/schemastery": "3.18.1",
@@ -63,7 +63,7 @@
63
63
  "js-yaml": "^4.1.0"
64
64
  },
65
65
  "devDependencies": {
66
- "@deepseek-ai/cordis": "4.0.1",
66
+ "@deepseek-ai/cordis": "4.0.2",
67
67
  "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
68
68
  "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
69
69
  "@deepseek-ai/dsh-llm-mock-server": "0.1.0-rc.6",