dsh-dlp 0.8.1 → 0.9.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 +18 -6
- package/cordis.patch.yml +2 -0
- package/lib/index.js +60 -2
- package/lib/policy.js +6 -0
- package/lib/results.js +2 -2
- package/lib/steps.js +117 -0
- package/lib/types/index.d.ts +12 -1
- package/lib/types/policy.d.ts +18 -1
- package/lib/types/results.d.ts +39 -0
- package/lib/types/sink.d.ts +9 -1
- package/lib/types/steps.d.ts +99 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -12,24 +12,34 @@ built as an out-of-repo plugin.
|
|
|
12
12
|
following symlinks first.
|
|
13
13
|
2. **Redacts secrets out of tool results** before the model reads them and before the session log
|
|
14
14
|
records them, withholding a result it cannot clean.
|
|
15
|
-
3. **Redacts secrets out of
|
|
15
|
+
3. **Redacts secrets out of the messages a step enters with** — the context a listener splices
|
|
16
|
+
in (the workspace `AGENTS.md`/`CLAUDE.md` chain, a captured tmux pane, a hook's
|
|
17
|
+
`additionalContext`, a skill body a `/name` token loaded) and the input the loop claimed from
|
|
18
|
+
the inbox that the user did not type (a `dsh-webhook` delivery's third-party payload, a
|
|
19
|
+
settled subagent result, an agent relay). All of it reaches the model and the durable log
|
|
20
|
+
through `agent/pre-step` without ever being a tool result. A message whose `source.kind` is
|
|
21
|
+
`user` is exempt: a secret a person deliberately types into their own prompt is not a leak
|
|
22
|
+
this plugin intercepts. The `agent/inbox/spliced` delivery record keeps a delivery's original
|
|
23
|
+
text, which is deliberate — it derives no model message, and an operator investigating a
|
|
24
|
+
webhook incident needs to read what was actually delivered.
|
|
25
|
+
4. **Redacts secrets out of exported telemetry**, closing a hole where `DSH_TELEMETRY_MODE=FULL`
|
|
16
26
|
ships message text, tool arguments, results and workspace paths in the clear.
|
|
17
|
-
|
|
27
|
+
5. **Strips invisible characters that carry hidden instructions** — the Tags block, bidi
|
|
18
28
|
overrides, runs of variation selectors — and strips terminal control sequences from the audit
|
|
19
29
|
lane so a tool result cannot forge its own audit record.
|
|
20
|
-
|
|
30
|
+
6. **Neutralises remote markdown images in assistant output** and detects a tool call another
|
|
21
31
|
plugin rewrote after the session log recorded it.
|
|
22
|
-
|
|
32
|
+
7. **Asks before the agent writes a file that changes future behaviour** — agent settings and
|
|
23
33
|
hooks, `CLAUDE.md`, `.claude/rules/**` and the other agent rules directories, prompt
|
|
24
34
|
templates, `.vscode/tasks.json`, `.mcp.json`, git hooks, CI workflows, shell startup files,
|
|
25
35
|
`pnpm-workspace.yaml` — and before it writes a `*_BASE_URL` that would redirect a provider
|
|
26
36
|
credential.
|
|
27
|
-
|
|
37
|
+
8. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
|
|
28
38
|
`approval_mode: auto`, an `apply` whose approval is still pending. Both `ask` tiers are
|
|
29
39
|
prompts rather than controls: they live at `tools/pre-execute`, they can be neutralised, and
|
|
30
40
|
they abstain wherever the approval seam prompts nobody — which includes every install under
|
|
31
41
|
`DSH_PERMISSION_MODE=danger-full-access` and a stock headless install under any mode.
|
|
32
|
-
|
|
42
|
+
9. **Writes an audit record for every decision.** A redaction or denial names the rule, its
|
|
33
43
|
version, the offsets and a keyed hash; the three kinds with no matched region to describe —
|
|
34
44
|
an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
|
|
35
45
|
the destination hostname instead. Never the secret, never the path or command that matched.
|
|
@@ -98,6 +108,8 @@ load.
|
|
|
98
108
|
breadthTier: true
|
|
99
109
|
resultRedaction: true
|
|
100
110
|
telemetryRedaction: true
|
|
111
|
+
stepContextRedaction: true
|
|
112
|
+
claimedInputRedaction: true
|
|
101
113
|
configWriteAsk: true
|
|
102
114
|
approvalSuppressionAsk: true
|
|
103
115
|
```
|
package/cordis.patch.yml
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `dsh-dlp` — data-loss prevention for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Seven registrations, in descending order of how much they can be trusted:
|
|
5
5
|
*
|
|
6
6
|
* 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
|
|
7
7
|
* credential paths named in a path-typed argument and for secrets heading
|
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
20
20
|
* Prepended, so it redacts what the rest of the waterfall returned; a
|
|
21
21
|
* listener registering later with the same option still runs ahead of it.
|
|
22
|
+
* 3b. `agent/pre-step` — redaction of the messages one step enters with: the
|
|
23
|
+
* context a listener splices in (the workspace instruction chain, the
|
|
24
|
+
* session skill catalog, a captured terminal pane, a hook's
|
|
25
|
+
* `additionalContext`) and the input the loop claimed from the inbox that
|
|
26
|
+
* the user did not type (a `dsh-webhook` delivery, a subagent's settled
|
|
27
|
+
* result, an agent relay). The loop appends what this waterfall returns as
|
|
28
|
+
* the `user/message` surface events every request is derived from, so the
|
|
29
|
+
* model-visible durable copy is the redacted one. A claimed message's
|
|
30
|
+
* earlier `agent/inbox/spliced` delivery record keeps the original; it
|
|
31
|
+
* derives no model message, and ADR §30 says why that is the wanted
|
|
32
|
+
* asymmetry. `source.kind: 'user'` is exempt.
|
|
22
33
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
23
34
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
24
35
|
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
|
@@ -47,6 +58,7 @@ import { evaluateConfigWrite } from "./config-writes.js";
|
|
|
47
58
|
import { evaluateApprovalSuppression } from "./approvals.js";
|
|
48
59
|
import { approvalSeamNotice, askReach } from "./approval-reach.js";
|
|
49
60
|
import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
|
|
61
|
+
import { redactStepContext } from "./steps.js";
|
|
50
62
|
import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
|
|
51
63
|
import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
|
|
52
64
|
export { Config } from "./policy.js";
|
|
@@ -364,6 +376,43 @@ export function apply(ctx, config) {
|
|
|
364
376
|
return redacted.decision;
|
|
365
377
|
}, { prepend: true });
|
|
366
378
|
}
|
|
379
|
+
if (policy.stepContextRedaction || policy.claimedInputRedaction) {
|
|
380
|
+
// One listener for both toggles: they cover two classes of message
|
|
381
|
+
// entering the same waterfall, and scanning them in one pass is what finds
|
|
382
|
+
// a secret split across the boundary between them. `redactStepContext`
|
|
383
|
+
// reads both flags and leaves an out-of-scope message as the object it
|
|
384
|
+
// arrived as.
|
|
385
|
+
//
|
|
386
|
+
// Prepended for the reason the result and telemetry seams are: listeners
|
|
387
|
+
// run outermost-first, so registering ahead of the chain is what lets this
|
|
388
|
+
// one redact the messages the rest of the waterfall spliced in rather than
|
|
389
|
+
// have its own replacement discarded afterwards.
|
|
390
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
391
|
+
const redacted = await redactStepContext(await next(), payload.messages, policy, hasher);
|
|
392
|
+
const indicators = Object.keys(redacted.indicators).length > 0
|
|
393
|
+
? { unicode: redacted.indicators }
|
|
394
|
+
: {};
|
|
395
|
+
if (redacted.spans.length > 0 || redacted.truncatedScan || Object.keys(indicators).length > 0) {
|
|
396
|
+
sink.write({
|
|
397
|
+
v: RECORD_VERSION,
|
|
398
|
+
time: new Date().toISOString(),
|
|
399
|
+
kind: 'step-context-redaction',
|
|
400
|
+
decisionId: newDecisionId(),
|
|
401
|
+
sessionId: String(payload.agent.session.id),
|
|
402
|
+
turn: payload.turn,
|
|
403
|
+
step: payload.step,
|
|
404
|
+
spans: redacted.spans,
|
|
405
|
+
...redacted.truncatedScan ? { truncatedScan: true } : {},
|
|
406
|
+
...indicators,
|
|
407
|
+
// Only when the pass covered claimed input: an operator counting
|
|
408
|
+
// deliveries must not have to read an empty list as "none arrived"
|
|
409
|
+
// on every workspace-instruction record.
|
|
410
|
+
...redacted.claimedSources.length > 0 ? { claimedSources: redacted.claimedSources } : {},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return redacted.decision;
|
|
414
|
+
}, { prepend: true });
|
|
415
|
+
}
|
|
367
416
|
if (policy.remoteImageNeutralization) {
|
|
368
417
|
ctx.on('llm/stream', (options, next) => neutralizeImageStream(next(), (host) => {
|
|
369
418
|
sink.write({
|
|
@@ -397,6 +446,15 @@ export function apply(ctx, config) {
|
|
|
397
446
|
});
|
|
398
447
|
}
|
|
399
448
|
return redacted.record;
|
|
400
|
-
|
|
449
|
+
// Prepended for the reason the other seams are: a listener that returns
|
|
450
|
+
// without calling `next()` vetoes every listener behind it for that
|
|
451
|
+
// dispatch — `Events.dispatch()` hands the waterfall a fresh array, so
|
|
452
|
+
// the registration survives and runs again on the next dispatch — and
|
|
453
|
+
// this one is the only thing standing between an exported telemetry
|
|
454
|
+
// record and the wire. One vetoed dispatch is one record exported in the
|
|
455
|
+
// clear, which is why position matters here. Best-effort, as at the
|
|
456
|
+
// mutation snapshot: another plugin registering later with the same
|
|
457
|
+
// option lands ahead again.
|
|
458
|
+
}, { prepend: true });
|
|
401
459
|
}
|
|
402
460
|
}
|
package/lib/policy.js
CHANGED
|
@@ -30,6 +30,8 @@ export const Config = z.object({
|
|
|
30
30
|
breadthTier: z.boolean().default(true),
|
|
31
31
|
resultRedaction: z.boolean().default(true),
|
|
32
32
|
telemetryRedaction: z.boolean().default(true),
|
|
33
|
+
stepContextRedaction: z.boolean().default(true),
|
|
34
|
+
claimedInputRedaction: z.boolean().default(true),
|
|
33
35
|
remoteImageNeutralization: z.boolean().default(true),
|
|
34
36
|
redactTelemetryWorkspacePaths: z.boolean().default(true),
|
|
35
37
|
configWriteAsk: z.boolean().default(true),
|
|
@@ -40,6 +42,8 @@ const ENABLEABLE = [
|
|
|
40
42
|
'breadthTier',
|
|
41
43
|
'resultRedaction',
|
|
42
44
|
'telemetryRedaction',
|
|
45
|
+
'stepContextRedaction',
|
|
46
|
+
'claimedInputRedaction',
|
|
43
47
|
'remoteImageNeutralization',
|
|
44
48
|
'redactTelemetryWorkspacePaths',
|
|
45
49
|
'configWriteAsk',
|
|
@@ -283,6 +287,8 @@ export function resolvePolicy(config, repo) {
|
|
|
283
287
|
breadthTier: enabled('breadthTier'),
|
|
284
288
|
resultRedaction: enabled('resultRedaction'),
|
|
285
289
|
telemetryRedaction: enabled('telemetryRedaction'),
|
|
290
|
+
stepContextRedaction: enabled('stepContextRedaction'),
|
|
291
|
+
claimedInputRedaction: enabled('claimedInputRedaction'),
|
|
286
292
|
remoteImageNeutralization: enabled('remoteImageNeutralization'),
|
|
287
293
|
redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
|
|
288
294
|
configWriteAsk: enabled('configWriteAsk'),
|
package/lib/results.js
CHANGED
|
@@ -44,7 +44,7 @@ const RENDER_SEPARATOR = '\n';
|
|
|
44
44
|
* @param policy - the effective policy.
|
|
45
45
|
* @returns a memoized lookup, the invisible-character counts, and whether tier 2 saw less than the whole rendering.
|
|
46
46
|
*/
|
|
47
|
-
async function prepareScan(strings, policy) {
|
|
47
|
+
export async function prepareScan(strings, policy) {
|
|
48
48
|
const rendered = strings.join(RENDER_SEPARATOR);
|
|
49
49
|
const { detections, truncated } = await scanAll(rendered, policy.syncRules, policy.maxScanBytes);
|
|
50
50
|
const memo = new Map();
|
|
@@ -87,7 +87,7 @@ function contentStrings(blocks) {
|
|
|
87
87
|
* @param messages - the contexts attached to a decision or ferried on a result.
|
|
88
88
|
* @returns every string those messages would put in front of the model or into the log.
|
|
89
89
|
*/
|
|
90
|
-
function messageStrings(messages) {
|
|
90
|
+
export function messageStrings(messages) {
|
|
91
91
|
return messages.flatMap(message => [...contentStrings(message.content), ...nestedStrings(message.source)]);
|
|
92
92
|
}
|
|
93
93
|
/**
|
package/lib/steps.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction of the messages one step enters with: the context a
|
|
3
|
+
* `agent/pre-step` listener splices in, and the input the loop claimed from
|
|
4
|
+
* the inbox that the user did not type.
|
|
5
|
+
*
|
|
6
|
+
* A tool result is not the only text that reaches the model. The agent loop
|
|
7
|
+
* dispatches `agent/pre-step` as a waterfall, appends every message the
|
|
8
|
+
* returned decision carries as a `user/message` surface event, and derives the
|
|
9
|
+
* next request from that surface. Two classes of text arrive that way.
|
|
10
|
+
*
|
|
11
|
+
* The first is what the waterfall itself adds, and nothing else scans it:
|
|
12
|
+
* `dsh-agent-instructions` splices the workspace `AGENTS.md`/`CLAUDE.md`
|
|
13
|
+
* chain, `dsh-tmux-context` splices captured pane text, the Claude Code and
|
|
14
|
+
* Codex hook bridges splice a hook command's `additionalContext`, and
|
|
15
|
+
* `dsh-tool-skill` splices a skill body a `/name` token asked for.
|
|
16
|
+
*
|
|
17
|
+
* The second is what the loop claimed from the inbox. Most of that is not the
|
|
18
|
+
* user typing either: `dsh-webhook` admits a verified third-party delivery's
|
|
19
|
+
* payload with `agent.followup()`, a subagent's settled result and an
|
|
20
|
+
* agent-to-agent relay arrive the same way, and so does anything
|
|
21
|
+
* `agent.inject()` seeded. {@link isUserTyped} is the exemption, and it is the
|
|
22
|
+
* only one: a secret a person deliberately types into their own prompt is not
|
|
23
|
+
* a leak this plugin intercepts.
|
|
24
|
+
*
|
|
25
|
+
* A claimed message differs from an added one in exactly one way, and it is
|
|
26
|
+
* narrower than it looks. Its delivery was already recorded as
|
|
27
|
+
* `agent/inbox/spliced` before this waterfall ran, and no seam can rewrite a
|
|
28
|
+
* committed event. That event is not a surface event — `SurfaceEventType` is
|
|
29
|
+
* `user/message | assistant/message | tool/result` — so it derives no model
|
|
30
|
+
* message and no request is built from it. The copy the model reads is the
|
|
31
|
+
* `user/message` the loop appends after this waterfall, which is the copy this
|
|
32
|
+
* pass rewrites. "Model-visible ⟺ logged" therefore still holds; what the
|
|
33
|
+
* delivery record keeps is the original text of an untrusted delivery, which
|
|
34
|
+
* is evidence rather than a disagreement. ADR §30 states that asymmetry, and
|
|
35
|
+
* `tests/e2e/step-context.e2e.ts` pins it.
|
|
36
|
+
*
|
|
37
|
+
* Best-effort, like every seam here that is not `ctx.tools.guard()`: this
|
|
38
|
+
* listener registers with `{ prepend: true }` so it sees what the rest of the
|
|
39
|
+
* waterfall settled on, and a listener registering later with the same option
|
|
40
|
+
* lands ahead of it again.
|
|
41
|
+
* @module dsh-dlp/steps
|
|
42
|
+
*/
|
|
43
|
+
import { messageStrings, prepareScan } from "./results.js";
|
|
44
|
+
import { redactUserMessages } from "./redaction.js";
|
|
45
|
+
/**
|
|
46
|
+
* Whether one message the loop claimed from the inbox is the user's own
|
|
47
|
+
* typing, and therefore exempt.
|
|
48
|
+
*
|
|
49
|
+
* `MessageSourceMap` is merge-extensible and every producer picks its own
|
|
50
|
+
* `kind`, so the exemption is a single allowed value rather than a list of
|
|
51
|
+
* denied ones: a source kind added by a package this plugin has never heard of
|
|
52
|
+
* is redacted rather than trusted. In the installed harness `kind: 'user'` is
|
|
53
|
+
* what the interactive entry points supply — `dsh-headless` for a CLI task,
|
|
54
|
+
* `dsh-acp` for an ACP prompt, `dsh-sdk-jsonrpc-server` for an SDK one, and
|
|
55
|
+
* `dsh-api-session-controller`'s `user-rpc` source for a browser prompt, which
|
|
56
|
+
* adds `rpcId` beside the same `kind`. `dsh-webhook`'s deliveries carry
|
|
57
|
+
* `kind: 'webhook'`, and the harness's own webhook invariant discriminates on
|
|
58
|
+
* exactly that value.
|
|
59
|
+
*
|
|
60
|
+
* Two producers borrow the value for text a person did not type: `dsh-subagent`
|
|
61
|
+
* and `dsh-subagent-in-process-driver` open a child agent with the parent's
|
|
62
|
+
* prompt under `kind: 'user'`. Those stay exempt here. Distinguishing them
|
|
63
|
+
* needs a fact the source does not carry, and inventing one would put a
|
|
64
|
+
* guessed value in a security decision.
|
|
65
|
+
* @param message - one message the loop claimed from the inbox.
|
|
66
|
+
* @returns whether the message came from a person typing into their own prompt.
|
|
67
|
+
*/
|
|
68
|
+
export function isUserTyped(message) {
|
|
69
|
+
return message.source.kind === 'user';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Redact the messages entering one step.
|
|
73
|
+
*
|
|
74
|
+
* @param decision - what the rest of the waterfall settled on.
|
|
75
|
+
* @param claimed - the messages the loop claimed from the inbox.
|
|
76
|
+
* @param policy - the effective policy after the tighten-only merge.
|
|
77
|
+
* @param hasher - mints each span's keyed hash.
|
|
78
|
+
* @returns the decision to return, plus what the pass found.
|
|
79
|
+
*/
|
|
80
|
+
export async function redactStepContext(decision, claimed, policy, hasher) {
|
|
81
|
+
const nothing = { spans: [], truncatedScan: false, indicators: {}, claimedSources: [] };
|
|
82
|
+
if (decision.kind !== 'enter')
|
|
83
|
+
return { decision, ...nothing };
|
|
84
|
+
// Object identity, which is what the loop and the shipped providers use: a
|
|
85
|
+
// provider splices its own message into the array it was handed, so a
|
|
86
|
+
// claimed message is the same object it arrived as.
|
|
87
|
+
const claimedSet = new Set(claimed);
|
|
88
|
+
const inScope = (message) => claimedSet.has(message)
|
|
89
|
+
? policy.claimedInputRedaction && !isUserTyped(message)
|
|
90
|
+
: policy.stepContextRedaction;
|
|
91
|
+
const selected = decision.messages.filter(inScope);
|
|
92
|
+
if (selected.length === 0)
|
|
93
|
+
return { decision, ...nothing };
|
|
94
|
+
const claimedSources = [...new Set(selected.filter(message => claimedSet.has(message)).map(message => message.source.kind))];
|
|
95
|
+
const prepared = await prepareScan(messageStrings(selected), policy);
|
|
96
|
+
const redacted = redactUserMessages(selected, prepared.scan, hasher, '/messages');
|
|
97
|
+
const base = {
|
|
98
|
+
spans: redacted.spans,
|
|
99
|
+
truncatedScan: prepared.truncated,
|
|
100
|
+
indicators: prepared.indicators,
|
|
101
|
+
claimedSources,
|
|
102
|
+
};
|
|
103
|
+
if (!redacted.changed)
|
|
104
|
+
return { decision, ...base };
|
|
105
|
+
// Rebuilt position by position so every message keeps the place it had:
|
|
106
|
+
// ordering is what decides whether a workspace instruction reads before or
|
|
107
|
+
// after the user's request.
|
|
108
|
+
const replacements = [...redacted.messages];
|
|
109
|
+
const messages = decision.messages.map((message) => {
|
|
110
|
+
if (!inScope(message))
|
|
111
|
+
return message;
|
|
112
|
+
const replacement = replacements.shift();
|
|
113
|
+
/* v8 ignore next -- `redactUserMessages` returns one message per input, so this never runs short. */
|
|
114
|
+
return replacement ?? message;
|
|
115
|
+
});
|
|
116
|
+
return { decision: { ...decision, messages }, ...base };
|
|
117
|
+
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `dsh-dlp` — data-loss prevention for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Seven registrations, in descending order of how much they can be trusted:
|
|
5
5
|
*
|
|
6
6
|
* 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
|
|
7
7
|
* credential paths named in a path-typed argument and for secrets heading
|
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
20
20
|
* Prepended, so it redacts what the rest of the waterfall returned; a
|
|
21
21
|
* listener registering later with the same option still runs ahead of it.
|
|
22
|
+
* 3b. `agent/pre-step` — redaction of the messages one step enters with: the
|
|
23
|
+
* context a listener splices in (the workspace instruction chain, the
|
|
24
|
+
* session skill catalog, a captured terminal pane, a hook's
|
|
25
|
+
* `additionalContext`) and the input the loop claimed from the inbox that
|
|
26
|
+
* the user did not type (a `dsh-webhook` delivery, a subagent's settled
|
|
27
|
+
* result, an agent relay). The loop appends what this waterfall returns as
|
|
28
|
+
* the `user/message` surface events every request is derived from, so the
|
|
29
|
+
* model-visible durable copy is the redacted one. A claimed message's
|
|
30
|
+
* earlier `agent/inbox/spliced` delivery record keeps the original; it
|
|
31
|
+
* derives no model message, and ADR §30 says why that is the wanted
|
|
32
|
+
* asymmetry. `source.kind: 'user'` is exempt.
|
|
22
33
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
23
34
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
24
35
|
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
package/lib/types/policy.d.ts
CHANGED
|
@@ -33,6 +33,21 @@ export interface Config {
|
|
|
33
33
|
resultRedaction: boolean;
|
|
34
34
|
/** Whether `session-telemetry/record` redaction runs. */
|
|
35
35
|
telemetryRedaction: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether the context an `agent/pre-step` listener splices into a step —
|
|
38
|
+
* the workspace instruction chain, captured terminal panes, a hook's
|
|
39
|
+
* `additionalContext`, a skill body a `/name` token asked for — is redacted
|
|
40
|
+
* before the loop logs it and builds the next request from it.
|
|
41
|
+
*/
|
|
42
|
+
stepContextRedaction: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the messages the loop claimed from the inbox are redacted before
|
|
45
|
+
* the step logs them and builds its request from them, for every message the
|
|
46
|
+
* user did not type: a `dsh-webhook` delivery's third-party payload, a
|
|
47
|
+
* subagent's settled result, an agent-to-agent relay, anything
|
|
48
|
+
* `agent.inject()` seeded. A message whose `source.kind` is `user` is exempt.
|
|
49
|
+
*/
|
|
50
|
+
claimedInputRedaction: boolean;
|
|
36
51
|
/** Whether remote markdown image destinations are neutralised in assistant output. */
|
|
37
52
|
remoteImageNeutralization: boolean;
|
|
38
53
|
/** Whether telemetry's `session.cwd` attribute is replaced with a keyed hash. */
|
|
@@ -52,7 +67,7 @@ export interface Config {
|
|
|
52
67
|
}
|
|
53
68
|
export declare const Config: z<Config>;
|
|
54
69
|
/** Config toggles a repo-local policy may switch on, and never off. */
|
|
55
|
-
declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk", "approvalSuppressionAsk"];
|
|
70
|
+
declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "stepContextRedaction", "claimedInputRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk", "approvalSuppressionAsk"];
|
|
56
71
|
/** One toggle name a repo-local policy may name in `enable`. */
|
|
57
72
|
export type EnableableToggle = typeof ENABLEABLE[number];
|
|
58
73
|
/** Payload version this package writes and accepts for repo-local policy files. */
|
|
@@ -73,6 +88,8 @@ export interface ResolvedPolicy {
|
|
|
73
88
|
readonly breadthTier: boolean;
|
|
74
89
|
readonly resultRedaction: boolean;
|
|
75
90
|
readonly telemetryRedaction: boolean;
|
|
91
|
+
readonly stepContextRedaction: boolean;
|
|
92
|
+
readonly claimedInputRedaction: boolean;
|
|
76
93
|
readonly remoteImageNeutralization: boolean;
|
|
77
94
|
readonly redactTelemetryWorkspacePaths: boolean;
|
|
78
95
|
readonly configWriteAsk: boolean;
|
package/lib/types/results.d.ts
CHANGED
|
@@ -13,9 +13,48 @@
|
|
|
13
13
|
* whole rule set applies here and not in the guard.
|
|
14
14
|
* @module dsh-dlp/results
|
|
15
15
|
*/
|
|
16
|
+
import type { UserMessage } from '@deepseek-ai/dsh-llm';
|
|
16
17
|
import type { JsonSchemaNode, PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools';
|
|
18
|
+
import { type Detection } from './detectors.ts';
|
|
17
19
|
import type { ResolvedPolicy } from './policy.ts';
|
|
18
20
|
import { type RedactedSpan, type SpanHasher } from './redaction.ts';
|
|
21
|
+
/** A scan function over already-scanned strings, plus how complete the scan was. */
|
|
22
|
+
export interface PreparedScan {
|
|
23
|
+
readonly scan: (text: string) => readonly Detection[];
|
|
24
|
+
readonly truncated: boolean;
|
|
25
|
+
/** Runs of each invisible-character class in the whole rendering, by rule id. */
|
|
26
|
+
readonly indicators: Readonly<Record<string, number>>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Scan a set of strings once and hand back a synchronous lookup, so the
|
|
30
|
+
* redaction walkers stay synchronous while detection stays async.
|
|
31
|
+
*
|
|
32
|
+
* The strings are scanned twice over: each on its own by tier 1, and all of
|
|
33
|
+
* them joined by both tiers. The joined pass is what finds a secret split
|
|
34
|
+
* across strings — a PEM block arriving as one `lines[i].text` per line, a
|
|
35
|
+
* token spanning two content blocks — which no per-string walk can see; its
|
|
36
|
+
* offsets are then mapped back onto the individual strings, because that is
|
|
37
|
+
* what the redaction walkers splice.
|
|
38
|
+
*
|
|
39
|
+
* Tier 2 runs once, over the joined text, and is budgeted by characters
|
|
40
|
+
* through `maxScanBytes`. One `lintSource` call per string would multiply a
|
|
41
|
+
* fixed per-call cost by however many pieces the tool happened to split its
|
|
42
|
+
* output into, and a budget counted in strings would make how much of a result
|
|
43
|
+
* is scanned depend on the same accident.
|
|
44
|
+
* @param strings - every string that will be redacted, in render order.
|
|
45
|
+
* @param policy - the effective policy.
|
|
46
|
+
* @returns a memoized lookup, the invisible-character counts, and whether tier 2 saw less than the whole rendering.
|
|
47
|
+
*/
|
|
48
|
+
export declare function prepareScan(strings: readonly string[], policy: ResolvedPolicy): Promise<PreparedScan>;
|
|
49
|
+
/**
|
|
50
|
+
* Text one set of `additionalContexts` carries: the model-facing blocks, plus
|
|
51
|
+
* whatever text the source records beside them — a `snapshot` source repeats
|
|
52
|
+
* the block text in `sections[].text`, a `notice` source its opening in
|
|
53
|
+
* `summary`, and a plugin's own source kind may carry more.
|
|
54
|
+
* @param messages - the contexts attached to a decision or ferried on a result.
|
|
55
|
+
* @returns every string those messages would put in front of the model or into the log.
|
|
56
|
+
*/
|
|
57
|
+
export declare function messageStrings(messages: readonly UserMessage[]): string[];
|
|
19
58
|
/** What a redaction pass produced, before an arm is chosen. */
|
|
20
59
|
export interface ResultRedaction {
|
|
21
60
|
readonly decision: PostToolDecision;
|
package/lib/types/sink.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare const RECORD_VERSION = 1;
|
|
|
38
38
|
*/
|
|
39
39
|
export declare const AUDIT_MODE = 416;
|
|
40
40
|
/** What produced one audit record. */
|
|
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';
|
|
41
|
+
export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | 'pre-execute-ask-abstained' | 'execution-mutation' | 'result-redaction' | 'step-context-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
|
|
42
42
|
/** One durable record. Never carries matched secret text. */
|
|
43
43
|
export interface AuditRecord {
|
|
44
44
|
readonly v: number;
|
|
@@ -83,6 +83,14 @@ export interface AuditRecord {
|
|
|
83
83
|
* operator needs to change to get the prompt back.
|
|
84
84
|
*/
|
|
85
85
|
readonly askUnreachable?: AskUnreachable;
|
|
86
|
+
/**
|
|
87
|
+
* The distinct `source.kind` of the inbox-claimed messages a
|
|
88
|
+
* `step-context-redaction` pass covered, present only when it covered any.
|
|
89
|
+
* A kind names the producer that delivered the text — `webhook` for a
|
|
90
|
+
* `dsh-webhook` delivery — which is what separates a redacted third-party
|
|
91
|
+
* payload from a redacted workspace instruction file.
|
|
92
|
+
*/
|
|
93
|
+
readonly claimedSources?: readonly string[];
|
|
86
94
|
/** Telemetry record channel, for `telemetry-redaction`. */
|
|
87
95
|
readonly channel?: string;
|
|
88
96
|
/** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction of the messages one step enters with: the context a
|
|
3
|
+
* `agent/pre-step` listener splices in, and the input the loop claimed from
|
|
4
|
+
* the inbox that the user did not type.
|
|
5
|
+
*
|
|
6
|
+
* A tool result is not the only text that reaches the model. The agent loop
|
|
7
|
+
* dispatches `agent/pre-step` as a waterfall, appends every message the
|
|
8
|
+
* returned decision carries as a `user/message` surface event, and derives the
|
|
9
|
+
* next request from that surface. Two classes of text arrive that way.
|
|
10
|
+
*
|
|
11
|
+
* The first is what the waterfall itself adds, and nothing else scans it:
|
|
12
|
+
* `dsh-agent-instructions` splices the workspace `AGENTS.md`/`CLAUDE.md`
|
|
13
|
+
* chain, `dsh-tmux-context` splices captured pane text, the Claude Code and
|
|
14
|
+
* Codex hook bridges splice a hook command's `additionalContext`, and
|
|
15
|
+
* `dsh-tool-skill` splices a skill body a `/name` token asked for.
|
|
16
|
+
*
|
|
17
|
+
* The second is what the loop claimed from the inbox. Most of that is not the
|
|
18
|
+
* user typing either: `dsh-webhook` admits a verified third-party delivery's
|
|
19
|
+
* payload with `agent.followup()`, a subagent's settled result and an
|
|
20
|
+
* agent-to-agent relay arrive the same way, and so does anything
|
|
21
|
+
* `agent.inject()` seeded. {@link isUserTyped} is the exemption, and it is the
|
|
22
|
+
* only one: a secret a person deliberately types into their own prompt is not
|
|
23
|
+
* a leak this plugin intercepts.
|
|
24
|
+
*
|
|
25
|
+
* A claimed message differs from an added one in exactly one way, and it is
|
|
26
|
+
* narrower than it looks. Its delivery was already recorded as
|
|
27
|
+
* `agent/inbox/spliced` before this waterfall ran, and no seam can rewrite a
|
|
28
|
+
* committed event. That event is not a surface event — `SurfaceEventType` is
|
|
29
|
+
* `user/message | assistant/message | tool/result` — so it derives no model
|
|
30
|
+
* message and no request is built from it. The copy the model reads is the
|
|
31
|
+
* `user/message` the loop appends after this waterfall, which is the copy this
|
|
32
|
+
* pass rewrites. "Model-visible ⟺ logged" therefore still holds; what the
|
|
33
|
+
* delivery record keeps is the original text of an untrusted delivery, which
|
|
34
|
+
* is evidence rather than a disagreement. ADR §30 states that asymmetry, and
|
|
35
|
+
* `tests/e2e/step-context.e2e.ts` pins it.
|
|
36
|
+
*
|
|
37
|
+
* Best-effort, like every seam here that is not `ctx.tools.guard()`: this
|
|
38
|
+
* listener registers with `{ prepend: true }` so it sees what the rest of the
|
|
39
|
+
* waterfall settled on, and a listener registering later with the same option
|
|
40
|
+
* lands ahead of it again.
|
|
41
|
+
* @module dsh-dlp/steps
|
|
42
|
+
*/
|
|
43
|
+
import type { PreStepDecision } from '@deepseek-ai/dsh-agent';
|
|
44
|
+
import type { UserMessage } from '@deepseek-ai/dsh-llm';
|
|
45
|
+
import type { ResolvedPolicy } from './policy.ts';
|
|
46
|
+
import { type RedactedSpan, type SpanHasher } from './redaction.ts';
|
|
47
|
+
/** What one pass over a step's entering messages produced. */
|
|
48
|
+
export interface StepRedaction {
|
|
49
|
+
/** The decision to return: the same object when nothing was replaced. */
|
|
50
|
+
readonly decision: PreStepDecision;
|
|
51
|
+
/** The regions replaced, described by rule identity, offsets and keyed hash only. */
|
|
52
|
+
readonly spans: readonly RedactedSpan[];
|
|
53
|
+
/** Set when the scanned text exceeded `maxScanBytes` and tier 2 saw less than all of it. */
|
|
54
|
+
readonly truncatedScan: boolean;
|
|
55
|
+
/** Runs of each invisible-character class the scanned messages carried, by rule id. */
|
|
56
|
+
readonly indicators: Readonly<Record<string, number>>;
|
|
57
|
+
/**
|
|
58
|
+
* The distinct `source.kind` of every inbox-claimed message this pass
|
|
59
|
+
* scanned, in first-seen order. Empty when the pass only covered messages
|
|
60
|
+
* the waterfall added. An operator reading the sink needs this to tell a
|
|
61
|
+
* redacted workspace instruction from a redacted third-party delivery.
|
|
62
|
+
*/
|
|
63
|
+
readonly claimedSources: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether one message the loop claimed from the inbox is the user's own
|
|
67
|
+
* typing, and therefore exempt.
|
|
68
|
+
*
|
|
69
|
+
* `MessageSourceMap` is merge-extensible and every producer picks its own
|
|
70
|
+
* `kind`, so the exemption is a single allowed value rather than a list of
|
|
71
|
+
* denied ones: a source kind added by a package this plugin has never heard of
|
|
72
|
+
* is redacted rather than trusted. In the installed harness `kind: 'user'` is
|
|
73
|
+
* what the interactive entry points supply — `dsh-headless` for a CLI task,
|
|
74
|
+
* `dsh-acp` for an ACP prompt, `dsh-sdk-jsonrpc-server` for an SDK one, and
|
|
75
|
+
* `dsh-api-session-controller`'s `user-rpc` source for a browser prompt, which
|
|
76
|
+
* adds `rpcId` beside the same `kind`. `dsh-webhook`'s deliveries carry
|
|
77
|
+
* `kind: 'webhook'`, and the harness's own webhook invariant discriminates on
|
|
78
|
+
* exactly that value.
|
|
79
|
+
*
|
|
80
|
+
* Two producers borrow the value for text a person did not type: `dsh-subagent`
|
|
81
|
+
* and `dsh-subagent-in-process-driver` open a child agent with the parent's
|
|
82
|
+
* prompt under `kind: 'user'`. Those stay exempt here. Distinguishing them
|
|
83
|
+
* needs a fact the source does not carry, and inventing one would put a
|
|
84
|
+
* guessed value in a security decision.
|
|
85
|
+
* @param message - one message the loop claimed from the inbox.
|
|
86
|
+
* @returns whether the message came from a person typing into their own prompt.
|
|
87
|
+
*/
|
|
88
|
+
export declare function isUserTyped(message: UserMessage): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Redact the messages entering one step.
|
|
91
|
+
*
|
|
92
|
+
* @param decision - what the rest of the waterfall settled on.
|
|
93
|
+
* @param claimed - the messages the loop claimed from the inbox.
|
|
94
|
+
* @param policy - the effective policy after the tighten-only merge.
|
|
95
|
+
* @param hasher - mints each span's keyed hash.
|
|
96
|
+
* @returns the decision to return, plus what the pass found.
|
|
97
|
+
*/
|
|
98
|
+
export declare function redactStepContext(decision: PreStepDecision, claimed: readonly UserMessage[], policy: ResolvedPolicy, hasher: SpanHasher): Promise<StepRedaction>;
|
|
99
|
+
//# sourceMappingURL=steps.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dlp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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>",
|
|
@@ -51,10 +51,11 @@
|
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
54
|
-
"@deepseek-ai/dsh-
|
|
55
|
-
"@deepseek-ai/dsh-
|
|
56
|
-
"@deepseek-ai/dsh-session
|
|
57
|
-
"@deepseek-ai/dsh-
|
|
54
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0",
|
|
55
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0",
|
|
56
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0",
|
|
57
|
+
"@deepseek-ai/dsh-session-telemetry": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0",
|
|
58
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0"
|
|
58
59
|
},
|
|
59
60
|
"dependencies": {
|
|
60
61
|
"@deepseek-ai/schemastery": "3.18.1",
|