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 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 exported telemetry**, closing a hole where `DSH_TELEMETRY_MODE=FULL`
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
- 4. **Strips invisible characters that carry hidden instructions** — the Tags block, bidi
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
- 5. **Neutralises remote markdown images in assistant output** and detects a tool call another
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
- 6. **Asks before the agent writes a file that changes future behaviour** — agent settings and
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
- 7. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
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
- 8. **Writes an audit record for every decision.** A redaction or denial names the rule, its
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
@@ -19,6 +19,8 @@
19
19
  breadthTier: true
20
20
  resultRedaction: true
21
21
  telemetryRedaction: true
22
+ stepContextRedaction: true
23
+ claimedInputRedaction: true
22
24
  remoteImageNeutralization: true
23
25
  redactTelemetryWorkspacePaths: true
24
26
  configWriteAsk: true
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dsh-dlp` — data-loss prevention for DeepSeek Harness.
3
3
  *
4
- * Six registrations, in descending order of how much they can be trusted:
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
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dsh-dlp` — data-loss prevention for DeepSeek Harness.
3
3
  *
4
- * Six registrations, in descending order of how much they can be trusted:
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
@@ -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;
@@ -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;
@@ -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.8.1",
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-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"
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",