dsh-dlp 0.7.0 → 0.8.1
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 +10 -6
- package/lib/cli.js +9 -2
- package/lib/detectors.js +30 -2
- package/lib/index.js +8 -1
- package/lib/redaction.js +83 -24
- package/lib/results.js +79 -34
- package/lib/sink.js +20 -3
- package/lib/telemetry.js +1 -1
- package/lib/types/cli.d.ts +6 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/redaction.d.ts +42 -6
- package/lib/types/results.d.ts +22 -4
- package/lib/types/sink.d.ts +13 -1
- package/package.json +7 -7
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:
|
|
59
|
-
threshold
|
|
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
|
|
76
|
-
|
|
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`. The `0.1.2` prerelease line is watched by a non-blocking job
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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,
|
|
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
|
|
8
|
-
*
|
|
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,
|
|
125
|
-
* Blocking replaces the whole result, which
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
*
|
package/lib/types/cli.d.ts
CHANGED
|
@@ -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.
|
package/lib/types/index.d.ts
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
|
package/lib/types/redaction.d.ts
CHANGED
|
@@ -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
|
package/lib/types/results.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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,
|
|
44
|
-
* Blocking replaces the whole result, which
|
|
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
|
package/lib/types/sink.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.8.1",
|
|
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.1.1-rc.0",
|
|
55
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ~0.1.1-rc.0",
|
|
56
|
+
"@deepseek-ai/dsh-session-telemetry": "^0.1.0-rc.6 || ~0.1.1-rc.0",
|
|
57
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ~0.1.1-rc.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.
|
|
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",
|