dsh-dlp 0.8.2 → 0.10.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,28 +12,42 @@ 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 below `aggressiveness: high`; at `high` the user's own typed prompt is
22
+ redacted too, because this plugin cannot know which provider the request is bound for. The
23
+ `agent/inbox/spliced` delivery record keeps a delivery's original text, which is deliberate —
24
+ it derives no model message, and an operator investigating a webhook incident needs to read
25
+ what was actually delivered.
26
+ 4. **Redacts secrets out of exported telemetry**, closing a hole where `DSH_TELEMETRY_MODE=FULL`
16
27
  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
28
+ 5. **Detects payment card numbers** issuer range, a length that issuer assigns, and a Luhn
29
+ check digit — so cardholder data does not reach a third-party model in a tool result, a
30
+ spliced file, exported telemetry, or (at `aggressiveness: high`) a prompt someone typed.
31
+ 6. **Strips invisible characters that carry hidden instructions** — the Tags block, bidi
18
32
  overrides, runs of variation selectors — and strips terminal control sequences from the audit
19
33
  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
34
+ 7. **Neutralises remote markdown images in assistant output** and detects a tool call another
21
35
  plugin rewrote after the session log recorded it.
22
- 6. **Asks before the agent writes a file that changes future behaviour** — agent settings and
36
+ 8. **Asks before the agent writes a file that changes future behaviour** — agent settings and
23
37
  hooks, `CLAUDE.md`, `.claude/rules/**` and the other agent rules directories, prompt
24
38
  templates, `.vscode/tasks.json`, `.mcp.json`, git hooks, CI workflows, shell startup files,
25
39
  `pnpm-workspace.yaml` — and before it writes a `*_BASE_URL` that would redirect a provider
26
40
  credential.
27
- 7. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
41
+ 9. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
28
42
  `approval_mode: auto`, an `apply` whose approval is still pending. Both `ask` tiers are
29
43
  prompts rather than controls: they live at `tools/pre-execute`, they can be neutralised, and
30
44
  they abstain wherever the approval seam prompts nobody — which includes every install under
31
45
  `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
33
- version, the offsets and a keyed hash; the three kinds with no matched region to describe —
34
- an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
35
- the destination hostname instead. Never the secret, never the path or command that matched.
36
- `dsh-dlp report` reads it back.
46
+ 10. **Writes an audit record for every decision.** A redaction or denial names the rule, its
47
+ version, the offsets and a keyed hash; the three kinds with no matched region to describe —
48
+ an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
49
+ the destination hostname instead. Never the secret, never the path or command that matched.
50
+ `dsh-dlp report` reads it back.
37
51
 
38
52
  ## What this is not
39
53
 
@@ -59,7 +73,9 @@ Three limits worth knowing before you rely on it:
59
73
  control.**
60
74
  - **Detection is pattern-based.** No entropy rule (measured, not assumed: the lowest
61
75
  false-positive-free threshold cannot flag anything shorter than 64–66 characters). Encoded
62
- forms pass. A homoglyph defeats every rule in this package.
76
+ forms pass. A homoglyph defeats every rule in this package. The card rule is Luhn-validated
77
+ and range-checked and found nothing across 272,635 lines of real source and docs, but
78
+ a *uniformly random* 16-digit number trips it 2.7% of the time and Maestro is not covered.
63
79
 
64
80
  [The full list of limits →](https://charlotten7.github.io/dsh-dlp/)
65
81
 
@@ -95,9 +111,12 @@ load.
95
111
  auditLog: /var/log/dsh-dlp.audit.jsonl
96
112
  redactionKeyFile: /var/lib/dsh/dsh-dlp.redaction-key
97
113
  policyFile: ./.dsh-dlp.yml # optional, lowest trust
114
+ aggressiveness: medium # low | medium | high
98
115
  breadthTier: true
99
116
  resultRedaction: true
100
117
  telemetryRedaction: true
118
+ stepContextRedaction: true
119
+ claimedInputRedaction: true
101
120
  configWriteAsk: true
102
121
  approvalSuppressionAsk: true
103
122
  ```
@@ -106,10 +125,24 @@ load.
106
125
  version control — it is what makes a placeholder's hash keyed rather than a bare digest anyone
107
126
  holding a candidate secret could confirm.
108
127
 
128
+ **`aggressiveness` is one word for how far redaction reaches.** `low` guarantees nothing and lets
129
+ each toggle stand alone; `medium` — the default — guarantees every pass is on and that no toggle
130
+ can take one away; `high` adds the user's own typed prompt to what is redacted. It composes with
131
+ the toggles rather than overriding them: at `medium` and `high` a toggle set to `false`
132
+ contradicts the level and **fails the mount** with the fix in the message, rather than one setting
133
+ quietly beating the other.
134
+
135
+ > **Upgrading from 0.9.0.** The default `medium` matches the shipped toggle defaults exactly, so
136
+ > an install that never wrote a toggle is unchanged. An install that set any redaction toggle to
137
+ > `false` now refuses to mount; add `aggressiveness: low` to the same row and it means what it
138
+ > meant before. `high` is opt-in.
139
+
109
140
  **The guard floor has no configuration.** Credential-path denial and secret-argument denial are
110
141
  security invariants, not deployment-varying tunables. A repo-local `policyFile` is the lowest
111
142
  trust rank and may only *tighten*: add deny patterns, add egress tool names, raise a severity,
112
- switch a pass on. Any downgrade makes the whole file invalid.
143
+ switch a pass on. It cannot reach `aggressiveness` — raising the level would let a hostile
144
+ workspace put placeholders into the user's own prompt. Any downgrade makes the whole file
145
+ invalid.
113
146
 
114
147
  [Configuration reference →](https://charlotten7.github.io/dsh-dlp/configuration.html) ·
115
148
  [What gets denied →](https://charlotten7.github.io/dsh-dlp/denials.html) ·
package/cordis.patch.yml CHANGED
@@ -15,10 +15,18 @@
15
15
  config:
16
16
  auditLog: !!js dshHomePath('dsh-dlp.audit.jsonl')
17
17
  redactionKeyFile: !!js dshHomePath('dsh-dlp.redaction-key')
18
+ # `medium` guarantees every pass below is on and that none of them can
19
+ # be switched off; `high` adds the user's own typed prompt to what is
20
+ # redacted; `low` guarantees nothing and is what a deployment needing
21
+ # a pass off must set. A `false` below contradicting the level is a
22
+ # load-time error rather than a setting that quietly loses.
23
+ aggressiveness: medium
18
24
  maxScanBytes: 1048576
19
25
  breadthTier: true
20
26
  resultRedaction: true
21
27
  telemetryRedaction: true
28
+ stepContextRedaction: true
29
+ claimedInputRedaction: true
22
30
  remoteImageNeutralization: true
23
31
  redactTelemetryWorkspacePaths: true
24
32
  configWriteAsk: true
package/lib/detectors.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Two detection tiers and the vocabulary they share.
3
3
  *
4
- * Tier 1 is a synchronous table of prefix-anchored token formats, owned here
5
- * because two of the three seams this plugin uses are synchronous:
4
+ * Tier 1 is a synchronous table of token formats — prefix-anchored but for the
5
+ * check-digit-validated card rule — owned here because two of the three seams
6
+ * this plugin uses are synchronous:
6
7
  * `ToolGuard` returns `string | undefined` and the `session-telemetry/record`
7
8
  * waterfall returns a record, neither of which can await. Tier 2 wraps
8
9
  * `@secretlint/core`, which runs in-process with no subprocess but resolves a
@@ -31,16 +32,136 @@ export function severityRank(severity) {
31
32
  }
32
33
  /** Severity at or above which the guard floor denies rather than only redacting. */
33
34
  export const DENY_SEVERITY = 'high';
35
+ /**
36
+ * Whether a digit string satisfies the Luhn check digit (ISO/IEC 7812-1).
37
+ * @param digits - the number with every separator already removed.
38
+ * @returns whether the trailing check digit is consistent with the rest.
39
+ */
40
+ function luhnValid(digits) {
41
+ let sum = 0;
42
+ let double = false;
43
+ for (let index = digits.length - 1; index >= 0; index -= 1) {
44
+ let value = digits.charCodeAt(index) - 48;
45
+ if (double) {
46
+ value *= 2;
47
+ if (value > 9)
48
+ value -= 9;
49
+ }
50
+ sum += value;
51
+ double = !double;
52
+ }
53
+ return sum % 10 === 0;
54
+ }
55
+ /**
56
+ * Issuer identification ranges, each with the lengths that issuer assigns.
57
+ *
58
+ * The length is half of each rule. Luhn alone accepts one digit run in ten,
59
+ * and these ranges at these lengths accept 26.8% of the sixteen-digit space
60
+ * (measured over 200,000 numbers with the check digit forced valid), so it is
61
+ * the pair that separates a card number from an order id beginning with a 4.
62
+ *
63
+ * Maestro is deliberately absent. Its ranges run from `50` and `56`-`58`
64
+ * through a bare leading `6` at any length from 12 to 19, which is most of the
65
+ * six-prefixed numeric space at most of the lengths an identifier uses;
66
+ * including it would cost more ordinary text than it catches.
67
+ * `docs/redaction.md` records that gap rather than implying coverage.
68
+ */
69
+ const CARD_RANGES = [
70
+ { prefix: /^4/, lengths: [13, 16, 19] },
71
+ { prefix: /^5[1-5]/, lengths: [16] },
72
+ // Mastercard's 2-series, 222100 through 272099.
73
+ { prefix: /^2(?:22[1-9]|2[3-9]\d|[3-6]\d\d|7[01]\d|720)/, lengths: [16] },
74
+ { prefix: /^3[47]/, lengths: [15] },
75
+ // Discover: 6011, 622126-622925, 644-649, 65.
76
+ { prefix: /^6(?:011|5\d\d|4[4-9]\d|22(?:12[6-9]|1[3-9]\d|[2-8]\d\d|9[01]\d|92[0-5]))/, lengths: [16, 19] },
77
+ { prefix: /^35(?:2[89]|[3-8]\d)/, lengths: [16, 17, 18, 19] },
78
+ // Diners Club: 300-305, 3095, 36, 38, 39.
79
+ { prefix: /^3(?:0(?:[0-5]|95)|[689])/, lengths: [14, 16, 19] },
80
+ { prefix: /^62/, lengths: [16, 17, 18, 19] },
81
+ ];
82
+ /** Whether a bare digit string is a number some issuer would have handed out. */
83
+ function isIssuedCardNumber(digits) {
84
+ const issued = CARD_RANGES.some(range => range.lengths.includes(digits.length) && range.prefix.test(digits));
85
+ return issued && luhnValid(digits);
86
+ }
87
+ /**
88
+ * Shortest printed digit group.
89
+ *
90
+ * This is the constant that keeps an amount written with space thousands
91
+ * separators from reading as a card number. Measured over 200,000 random
92
+ * sixteen-digit amounts written `4 920 007 989 245 430`: admitting groups
93
+ * shorter than three flags 3.75% of them, requiring three flags 0.19%.
94
+ */
95
+ const MIN_CARD_GROUP = 3;
96
+ /** Longest printed digit group: American Express is printed 4-6-5. */
97
+ const MAX_CARD_GROUP = 6;
98
+ /** Most groups one match may span — a 19-digit number takes five, leaving room for one neighbour. */
99
+ const MAX_CARD_GROUPS = 6;
100
+ /** One printed group of a grouped card number. */
101
+ const CARD_GROUP = String.raw `\d{${MIN_CARD_GROUP},${MAX_CARD_GROUP}}`;
102
+ /**
103
+ * A digit run that could be a card number: either unbroken, or printed in
104
+ * groups separated by single spaces or hyphens.
105
+ *
106
+ * The two lookarounds drop the commonest long digit run in machine output that
107
+ * is not an identifier at all — the fractional part of a decimal. A JavaScript
108
+ * `Math.random()` prints sixteen digits after the point, and 1.63% of them get
109
+ * past both the issuer ranges and the check digit; measured over 200,000
110
+ * printouts, the lookbehind takes that to zero.
111
+ */
112
+ const CARD_CANDIDATE = new RegExp(String.raw `(?<!\d\.)\b(?:\d{13,19}|${CARD_GROUP}(?:[ -]${CARD_GROUP}){1,${MAX_CARD_GROUPS - 1}})\b(?!\.\d)`, 'g');
113
+ /**
114
+ * Narrow one candidate digit run to the card number inside it.
115
+ *
116
+ * The pattern is greedy across printed groups, so a run holding a card number
117
+ * beside an expiry date, an amount or a year arrives here as one match. Every
118
+ * group-aligned window of the run is tested, longest first, and each window's
119
+ * edges sit on a separator or on the ends of the match — where the pattern
120
+ * already established a word boundary — so narrowing never reports part of a
121
+ * longer unbroken number. An unbroken run has exactly one window, itself,
122
+ * which is what keeps a 20-digit identifier from being reported as the
123
+ * 16-digit card number hiding in its first sixteen digits.
124
+ * @param match - the matched candidate.
125
+ * @returns offsets of the card number within the match, or `undefined` when there is none.
126
+ */
127
+ export function refinePaymentCardNumber(match) {
128
+ const groups = [];
129
+ let cursor = 0;
130
+ for (const digits of match.split(/[ -]/)) {
131
+ groups.push({ start: cursor, end: cursor + digits.length, digits });
132
+ cursor += digits.length + 1;
133
+ }
134
+ const windows = [];
135
+ for (const [index, first] of groups.entries()) {
136
+ let digits = '';
137
+ for (const group of groups.slice(index)) {
138
+ digits += group.digits;
139
+ windows.push({ start: first.start, end: group.end, digits });
140
+ }
141
+ }
142
+ // Longest first, so a number is reported whole rather than as a prefix of
143
+ // itself that happens to validate at a shorter issuer length.
144
+ windows.sort((left, right) => (right.end - right.start) - (left.end - left.start));
145
+ for (const window of windows) {
146
+ if (isIssuedCardNumber(window.digits))
147
+ return { start: window.start, end: window.end };
148
+ }
149
+ return undefined;
150
+ }
34
151
  /**
35
152
  * Tier 1's rule table. Deliberately narrow: only formats whose prefix or
36
153
  * delimiters make a match structurally unambiguous, plus PEM blocks and
37
154
  * credential-bearing URLs. Anything requiring entropy heuristics is left to
38
155
  * tier 2, where a false positive costs a redaction rather than a denial.
39
156
  *
40
- * Prefix-anchored is the whole membership criterion, and the reason this table
41
- * keeps growing rather than deferring to tier 2: the `session-telemetry/record`
42
- * waterfall is synchronous and cannot reach tier 2 at all, so a format missing
43
- * here is exported in the clear when telemetry is on.
157
+ * A structurally unambiguous match is the membership criterion, and the reason
158
+ * this table keeps growing rather than deferring to tier 2: the
159
+ * `session-telemetry/record` waterfall is synchronous and cannot reach tier 2
160
+ * at all, so a format missing here is exported in the clear when telemetry is
161
+ * on. For every rule but one that means a prefix. The exception is
162
+ * `dsh-dlp/payment-card-number`, which has no prefix to anchor on and earns
163
+ * its place through an issuer range, a length that issuer assigns and a check
164
+ * digit instead.
44
165
  */
45
166
  export const SYNC_RULES = [
46
167
  { id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
@@ -126,6 +247,23 @@ export const SYNC_RULES = [
126
247
  { id: 'dsh-dlp/discord-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/(?:\w+\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]{10,}/g },
127
248
  { id: 'dsh-dlp/teams-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/[A-Za-z0-9.-]*webhook\.office\.com\/webhookb2\/[A-Za-z0-9@/_-]{10,}/g },
128
249
  { id: 'dsh-dlp/secret-assignment', version: 1, severity: 'medium', pattern: /\b(?:api[_-]?key|secret[_-]?key|client[_-]?secret|password|passwd|access[_-]?token|auth[_-]?token)\b\s*[=:]\s*["']?[A-Za-z0-9/+=_-]{16,}["']?/gi },
250
+ // Cardholder data, which is the one class here that is not a credential the
251
+ // agent could have fetched: it is typed, pasted or read out of a customer
252
+ // record, and it is regulated wherever it goes. `medium` is deliberate and
253
+ // is the only rule in this table below `high` other than the assignment
254
+ // heuristic above it. The guard floor denies at `high`, and a denial from
255
+ // this rule would be unoverridable, while its false positives are ordinary
256
+ // long numbers rather than malformed credentials. A deployment that wants
257
+ // the denial raises the severity from its repo-local policy, which the
258
+ // tighten-only tier already allows.
259
+ {
260
+ id: 'dsh-dlp/payment-card-number',
261
+ version: 1,
262
+ severity: 'medium',
263
+ pattern: CARD_CANDIDATE,
264
+ refine: refinePaymentCardNumber,
265
+ exact: true,
266
+ },
129
267
  ];
130
268
  /**
131
269
  * Build one class's run pattern from its ranges, so the two cannot drift apart.
@@ -318,13 +456,18 @@ export function scanSync(text, rules = SYNC_RULES) {
318
456
  for (const rule of rules) {
319
457
  for (const match of text.matchAll(rule.pattern)) {
320
458
  // `matchAll` on a global pattern always reports an index.
321
- const start = match.index;
459
+ const region = rule.refine === undefined
460
+ ? { start: 0, end: match[0].length }
461
+ : rule.refine(match[0]);
462
+ if (region === undefined)
463
+ continue;
322
464
  detections.push({
323
465
  ruleId: rule.id,
324
466
  ruleVersion: rule.version,
325
467
  severity: rule.severity,
326
- start,
327
- end: start + match[0].length,
468
+ start: match.index + region.start,
469
+ end: match.index + region.end,
470
+ ...rule.exact === true ? { exact: true } : {},
328
471
  });
329
472
  }
330
473
  }
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,11 +446,15 @@ export function apply(ctx, config) {
397
446
  });
398
447
  }
399
448
  return redacted.record;
400
- // Prepended for the reason the other two seams are: a listener that
401
- // returns without calling `next()` deletes every listener behind it, and
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
402
453
  // this one is the only thing standing between an exported telemetry
403
- // record and the wire. Best-effort, as at the mutation snapshot: another
404
- // plugin registering later with the same option lands ahead again.
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.
405
458
  }, { prepend: true });
406
459
  }
407
460
  }
package/lib/policy.js CHANGED
@@ -11,7 +11,10 @@
11
11
  * Rank 3 is a file inside the workspace, so a hostile repository ships one and
12
12
  * a prompt-injected agent can write one. It may add deny patterns, add egress
13
13
  * tools, raise a severity, and switch a redaction pass on. Every other key,
14
- * and every downgrade, is a load-time error rather than a silent ignore.
14
+ * and every downgrade, is a load-time error rather than a silent ignore. It
15
+ * cannot reach {@link Config.aggressiveness}: the level's own lever is
16
+ * redacting what the user typed, and a workspace that could force that could
17
+ * garble the user's own words on their way to the model.
15
18
  * @module dsh-dlp/policy
16
19
  */
17
20
  import { readFileSync } from 'node:fs';
@@ -23,6 +26,7 @@ import { SYNC_RULES, severityRank, stripControlSequences } from "./detectors.js"
23
26
  import { resolveDshHome } from "./home.js";
24
27
  import { CREDENTIAL_PATH_RULES, escapePathPattern, homeCredentialPathRules } from "./paths.js";
25
28
  export const Config = z.object({
29
+ aggressiveness: z.union([z.const('low'), z.const('medium'), z.const('high')]).default('medium'),
26
30
  auditLog: z.string().required(),
27
31
  redactionKeyFile: z.string().required(),
28
32
  policyFile: z.string(),
@@ -30,6 +34,8 @@ export const Config = z.object({
30
34
  breadthTier: z.boolean().default(true),
31
35
  resultRedaction: z.boolean().default(true),
32
36
  telemetryRedaction: z.boolean().default(true),
37
+ stepContextRedaction: z.boolean().default(true),
38
+ claimedInputRedaction: z.boolean().default(true),
33
39
  remoteImageNeutralization: z.boolean().default(true),
34
40
  redactTelemetryWorkspacePaths: z.boolean().default(true),
35
41
  configWriteAsk: z.boolean().default(true),
@@ -40,11 +46,46 @@ const ENABLEABLE = [
40
46
  'breadthTier',
41
47
  'resultRedaction',
42
48
  'telemetryRedaction',
49
+ 'stepContextRedaction',
50
+ 'claimedInputRedaction',
43
51
  'remoteImageNeutralization',
44
52
  'redactTelemetryWorkspacePaths',
45
53
  'configWriteAsk',
46
54
  'approvalSuppressionAsk',
47
55
  ];
56
+ /**
57
+ * Passes each level requires, whatever the toggles say.
58
+ *
59
+ * `low` requires none, which is what makes it the level a deployment moves to
60
+ * when it needs a pass off — and what makes every configuration that was legal
61
+ * before this field existed still legal. `medium` and `high` require all of
62
+ * them, so the one-word setting and the nine booleans can never describe
63
+ * different plugins.
64
+ */
65
+ const LEVEL_REQUIRES = {
66
+ low: [],
67
+ medium: ENABLEABLE,
68
+ high: ENABLEABLE,
69
+ };
70
+ /**
71
+ * Reject a configuration whose level and whose toggles describe different
72
+ * behaviour.
73
+ *
74
+ * Loud at load rather than silently resolved either way: two
75
+ * deployment-controlled settings disagreeing is self-contained
76
+ * misconfiguration, and whichever of them lost would be a pass an operator
77
+ * believes is running and is not, or one they believe is off and is not.
78
+ * @param config - the deployment-controlled configuration.
79
+ * @throws PolicyError naming every toggle that contradicts the level.
80
+ */
81
+ function assertLevelAgrees(config) {
82
+ const contradicted = LEVEL_REQUIRES[config.aggressiveness].filter(toggle => !config[toggle]);
83
+ if (contradicted.length === 0)
84
+ return;
85
+ throw new PolicyError(`aggressiveness: ${config.aggressiveness} requires every pass this package ships, but`
86
+ + ` ${contradicted.join(', ')} ${contradicted.length === 1 ? 'is' : 'are'} set to false.`
87
+ + ' Set aggressiveness: low to choose passes individually, or drop the false setting.');
88
+ }
48
89
  /** Keys a repo-local policy file may carry; anything else fails the load. */
49
90
  const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
50
91
  /** Payload version this package writes and accepts for repo-local policy files. */
@@ -266,6 +307,7 @@ function selfProtectionRules(config, dshHome) {
266
307
  * @returns the effective policy every seam reads.
267
308
  */
268
309
  export function resolvePolicy(config, repo) {
310
+ assertLevelAgrees(config);
269
311
  const enabled = (toggle) => config[toggle] || (repo?.enable.includes(toggle) ?? false);
270
312
  return {
271
313
  credentialPathRules: [
@@ -280,9 +322,13 @@ export function resolvePolicy(config, repo) {
280
322
  return raised === undefined ? rule : { ...rule, severity: raised };
281
323
  }),
282
324
  maxScanBytes: config.maxScanBytes,
325
+ aggressiveness: config.aggressiveness,
326
+ userTypedInputRedaction: config.aggressiveness === 'high',
283
327
  breadthTier: enabled('breadthTier'),
284
328
  resultRedaction: enabled('resultRedaction'),
285
329
  telemetryRedaction: enabled('telemetryRedaction'),
330
+ stepContextRedaction: enabled('stepContextRedaction'),
331
+ claimedInputRedaction: enabled('claimedInputRedaction'),
286
332
  remoteImageNeutralization: enabled('remoteImageNeutralization'),
287
333
  redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
288
334
  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,124 @@
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, but it is not unconditional: it applies at `aggressiveness: low`
23
+ * and `medium`, and stops applying at `high`. This plugin cannot know where
24
+ * the request is going, and a card number a person typed on purpose is still
25
+ * cardholder data at whatever provider the deployment happens to be pointed
26
+ * at, so the deployment says which reading it wants rather than this module
27
+ * assuming one.
28
+ *
29
+ * A claimed message differs from an added one in exactly one way, and it is
30
+ * narrower than it looks. Its delivery was already recorded as
31
+ * `agent/inbox/spliced` before this waterfall ran, and no seam can rewrite a
32
+ * committed event. That event is not a surface event — `SurfaceEventType` is
33
+ * `user/message | assistant/message | tool/result` — so it derives no model
34
+ * message and no request is built from it. The copy the model reads is the
35
+ * `user/message` the loop appends after this waterfall, which is the copy this
36
+ * pass rewrites. "Model-visible ⟺ logged" therefore still holds; what the
37
+ * delivery record keeps is the original text of an untrusted delivery, which
38
+ * is evidence rather than a disagreement. ADR §30 states that asymmetry, and
39
+ * `tests/e2e/step-context.e2e.ts` pins it.
40
+ *
41
+ * Best-effort, like every seam here that is not `ctx.tools.guard()`: this
42
+ * listener registers with `{ prepend: true }` so it sees what the rest of the
43
+ * waterfall settled on, and a listener registering later with the same option
44
+ * lands ahead of it again.
45
+ * @module dsh-dlp/steps
46
+ */
47
+ import { messageStrings, prepareScan } from "./results.js";
48
+ import { redactUserMessages } from "./redaction.js";
49
+ /**
50
+ * Whether one message the loop claimed from the inbox is the user's own
51
+ * typing, which exempts it below `aggressiveness: high`.
52
+ *
53
+ * `MessageSourceMap` is merge-extensible and every producer picks its own
54
+ * `kind`, so the exemption is a single allowed value rather than a list of
55
+ * denied ones: a source kind added by a package this plugin has never heard of
56
+ * is redacted rather than trusted. In the installed harness `kind: 'user'` is
57
+ * what the interactive entry points supply — `dsh-headless` for a CLI task,
58
+ * `dsh-acp` for an ACP prompt, `dsh-sdk-jsonrpc-server` for an SDK one, and
59
+ * `dsh-api-session-controller`'s `user-rpc` source for a browser prompt, which
60
+ * adds `rpcId` beside the same `kind`. `dsh-webhook`'s deliveries carry
61
+ * `kind: 'webhook'`, and the harness's own webhook invariant discriminates on
62
+ * exactly that value.
63
+ *
64
+ * Two producers borrow the value for text a person did not type: `dsh-subagent`
65
+ * and `dsh-subagent-in-process-driver` open a child agent with the parent's
66
+ * prompt under `kind: 'user'`. Those stay exempt here. Distinguishing them
67
+ * needs a fact the source does not carry, and inventing one would put a
68
+ * guessed value in a security decision.
69
+ *
70
+ * At `aggressiveness: high` the caller stops consulting this, so the two
71
+ * borrowed cases stop being exempt with everything else.
72
+ * @param message - one message the loop claimed from the inbox.
73
+ * @returns whether the message came from a person typing into their own prompt.
74
+ */
75
+ export function isUserTyped(message) {
76
+ return message.source.kind === 'user';
77
+ }
78
+ /**
79
+ * Redact the messages entering one step.
80
+ *
81
+ * @param decision - what the rest of the waterfall settled on.
82
+ * @param claimed - the messages the loop claimed from the inbox.
83
+ * @param policy - the effective policy after the tighten-only merge.
84
+ * @param hasher - mints each span's keyed hash.
85
+ * @returns the decision to return, plus what the pass found.
86
+ */
87
+ export async function redactStepContext(decision, claimed, policy, hasher) {
88
+ const nothing = { spans: [], truncatedScan: false, indicators: {}, claimedSources: [] };
89
+ if (decision.kind !== 'enter')
90
+ return { decision, ...nothing };
91
+ // Object identity, which is what the loop and the shipped providers use: a
92
+ // provider splices its own message into the array it was handed, so a
93
+ // claimed message is the same object it arrived as.
94
+ const claimedSet = new Set(claimed);
95
+ const inScope = (message) => claimedSet.has(message)
96
+ ? policy.claimedInputRedaction && (policy.userTypedInputRedaction || !isUserTyped(message))
97
+ : policy.stepContextRedaction;
98
+ const selected = decision.messages.filter(inScope);
99
+ if (selected.length === 0)
100
+ return { decision, ...nothing };
101
+ const claimedSources = [...new Set(selected.filter(message => claimedSet.has(message)).map(message => message.source.kind))];
102
+ const prepared = await prepareScan(messageStrings(selected), policy);
103
+ const redacted = redactUserMessages(selected, prepared.scan, hasher, '/messages');
104
+ const base = {
105
+ spans: redacted.spans,
106
+ truncatedScan: prepared.truncated,
107
+ indicators: prepared.indicators,
108
+ claimedSources,
109
+ };
110
+ if (!redacted.changed)
111
+ return { decision, ...base };
112
+ // Rebuilt position by position so every message keeps the place it had:
113
+ // ordering is what decides whether a workspace instruction reads before or
114
+ // after the user's request.
115
+ const replacements = [...redacted.messages];
116
+ const messages = decision.messages.map((message) => {
117
+ if (!inScope(message))
118
+ return message;
119
+ const replacement = replacements.shift();
120
+ /* v8 ignore next -- `redactUserMessages` returns one message per input, so this never runs short. */
121
+ return replacement ?? message;
122
+ });
123
+ return { decision: { ...decision, messages }, ...base };
124
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Two detection tiers and the vocabulary they share.
3
3
  *
4
- * Tier 1 is a synchronous table of prefix-anchored token formats, owned here
5
- * because two of the three seams this plugin uses are synchronous:
4
+ * Tier 1 is a synchronous table of token formats — prefix-anchored but for the
5
+ * check-digit-validated card rule — owned here because two of the three seams
6
+ * this plugin uses are synchronous:
6
7
  * `ToolGuard` returns `string | undefined` and the `session-telemetry/record`
7
8
  * waterfall returns a record, neither of which can await. Tier 2 wraps
8
9
  * `@secretlint/core`, which runs in-process with no subprocess but resolves a
@@ -39,8 +40,10 @@ export interface Detection {
39
40
  readonly end: number;
40
41
  /**
41
42
  * Set when the offsets cover exactly what must be replaced, so redaction
42
- * must not widen them to the surrounding delimiters. Only the Unicode
43
- * indicators set it: their matches are the characters themselves, while a
43
+ * must not widen them to the surrounding delimiters. Two kinds of rule set
44
+ * it: the Unicode indicators, whose matches are the characters themselves,
45
+ * and the payment-card rule, whose match is validated digit by digit and so
46
+ * covers precisely the number. Everything else leaves it unset, because a
44
47
  * secret's reported span is advisory and verified to under-cover (ADR §4).
45
48
  */
46
49
  readonly exact?: true;
@@ -58,17 +61,55 @@ export interface SyncRule {
58
61
  readonly severity: Severity;
59
62
  /** Global-flagged; matched through `matchAll`, which never mutates this instance's `lastIndex`. */
60
63
  readonly pattern: RegExp;
64
+ /**
65
+ * Narrows one raw match to the region that is really a detection, or rejects
66
+ * it outright by returning `undefined`. Offsets are relative to the match.
67
+ *
68
+ * Present for the one format a regular expression cannot decide on its own:
69
+ * a payment card number is a digit run that must also carry a known issuer
70
+ * prefix at a length that issuer assigns and satisfy a check digit, and the
71
+ * run it sits in may carry a neighbouring number the pattern swept up.
72
+ * Absent everywhere else, where the prefix makes the match unambiguous.
73
+ */
74
+ readonly refine?: (match: string) => {
75
+ readonly start: number;
76
+ readonly end: number;
77
+ } | undefined;
78
+ /** Whether the reported offsets cover exactly the text to replace; see {@link Detection.exact}. */
79
+ readonly exact?: true;
61
80
  }
81
+ /**
82
+ * Narrow one candidate digit run to the card number inside it.
83
+ *
84
+ * The pattern is greedy across printed groups, so a run holding a card number
85
+ * beside an expiry date, an amount or a year arrives here as one match. Every
86
+ * group-aligned window of the run is tested, longest first, and each window's
87
+ * edges sit on a separator or on the ends of the match — where the pattern
88
+ * already established a word boundary — so narrowing never reports part of a
89
+ * longer unbroken number. An unbroken run has exactly one window, itself,
90
+ * which is what keeps a 20-digit identifier from being reported as the
91
+ * 16-digit card number hiding in its first sixteen digits.
92
+ * @param match - the matched candidate.
93
+ * @returns offsets of the card number within the match, or `undefined` when there is none.
94
+ */
95
+ export declare function refinePaymentCardNumber(match: string): {
96
+ start: number;
97
+ end: number;
98
+ } | undefined;
62
99
  /**
63
100
  * Tier 1's rule table. Deliberately narrow: only formats whose prefix or
64
101
  * delimiters make a match structurally unambiguous, plus PEM blocks and
65
102
  * credential-bearing URLs. Anything requiring entropy heuristics is left to
66
103
  * tier 2, where a false positive costs a redaction rather than a denial.
67
104
  *
68
- * Prefix-anchored is the whole membership criterion, and the reason this table
69
- * keeps growing rather than deferring to tier 2: the `session-telemetry/record`
70
- * waterfall is synchronous and cannot reach tier 2 at all, so a format missing
71
- * here is exported in the clear when telemetry is on.
105
+ * A structurally unambiguous match is the membership criterion, and the reason
106
+ * this table keeps growing rather than deferring to tier 2: the
107
+ * `session-telemetry/record` waterfall is synchronous and cannot reach tier 2
108
+ * at all, so a format missing here is exported in the clear when telemetry is
109
+ * on. For every rule but one that means a prefix. The exception is
110
+ * `dsh-dlp/payment-card-number`, which has no prefix to anchor on and earns
111
+ * its place through an issuer range, a length that issuer assigns and a check
112
+ * digit instead.
72
113
  */
73
114
  export declare const SYNC_RULES: readonly SyncRule[];
74
115
  /**
@@ -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
@@ -11,14 +11,41 @@
11
11
  * Rank 3 is a file inside the workspace, so a hostile repository ships one and
12
12
  * a prompt-injected agent can write one. It may add deny patterns, add egress
13
13
  * tools, raise a severity, and switch a redaction pass on. Every other key,
14
- * and every downgrade, is a load-time error rather than a silent ignore.
14
+ * and every downgrade, is a load-time error rather than a silent ignore. It
15
+ * cannot reach {@link Config.aggressiveness}: the level's own lever is
16
+ * redacting what the user typed, and a workspace that could force that could
17
+ * garble the user's own words on their way to the model.
15
18
  * @module dsh-dlp/policy
16
19
  */
17
20
  import z from '@deepseek-ai/schemastery';
18
21
  import { type Severity, type SyncRule } from './detectors.ts';
19
22
  import { type CredentialPathRule } from './paths.ts';
23
+ /**
24
+ * How far redaction reaches, as one word.
25
+ *
26
+ * The level is not a tenth switch beside the nine below it. It states what the
27
+ * deployment guarantees, the toggles state which passes carry it out, and the
28
+ * two may not disagree: at `medium` and `high` a toggle set to `false`
29
+ * contradicts the guarantee and is a load-time error rather than a setting
30
+ * that quietly loses.
31
+ *
32
+ * - `low` guarantees nothing. Every pass is exactly its own toggle, which is
33
+ * still `true` unless the deployment says otherwise. This is the level to
34
+ * pick when a pass has to be switched off.
35
+ * - `medium` guarantees that every pass this package ships is on and that no
36
+ * toggle can take one away. The user's own typing stays exempt.
37
+ * - `high` is `medium` plus the one thing no toggle can express: the exemption
38
+ * in `isUserTyped` stops applying, so a secret in the user's own prompt is
39
+ * redacted before the request is built from it.
40
+ */
41
+ export type Aggressiveness = 'low' | 'medium' | 'high';
20
42
  /** Deployment configuration, validated from `cordis.yml`. */
21
43
  export interface Config {
44
+ /**
45
+ * How far redaction reaches. See {@link Aggressiveness}; `high` is the only
46
+ * value that puts the user's own typed prompt in scope.
47
+ */
48
+ aggressiveness: Aggressiveness;
22
49
  /** Absolute path of this plugin's own JSONL audit sink. Never the session log. */
23
50
  auditLog: string;
24
51
  /** Absolute path of the installation's redaction key; created with 32 random bytes if absent. */
@@ -33,6 +60,21 @@ export interface Config {
33
60
  resultRedaction: boolean;
34
61
  /** Whether `session-telemetry/record` redaction runs. */
35
62
  telemetryRedaction: boolean;
63
+ /**
64
+ * Whether the context an `agent/pre-step` listener splices into a step —
65
+ * the workspace instruction chain, captured terminal panes, a hook's
66
+ * `additionalContext`, a skill body a `/name` token asked for — is redacted
67
+ * before the loop logs it and builds the next request from it.
68
+ */
69
+ stepContextRedaction: boolean;
70
+ /**
71
+ * Whether the messages the loop claimed from the inbox are redacted before
72
+ * the step logs them and builds its request from them, for every message the
73
+ * user did not type: a `dsh-webhook` delivery's third-party payload, a
74
+ * subagent's settled result, an agent-to-agent relay, anything
75
+ * `agent.inject()` seeded. A message whose `source.kind` is `user` is exempt.
76
+ */
77
+ claimedInputRedaction: boolean;
36
78
  /** Whether remote markdown image destinations are neutralised in assistant output. */
37
79
  remoteImageNeutralization: boolean;
38
80
  /** Whether telemetry's `session.cwd` attribute is replaced with a keyed hash. */
@@ -52,7 +94,7 @@ export interface Config {
52
94
  }
53
95
  export declare const Config: z<Config>;
54
96
  /** Config toggles a repo-local policy may switch on, and never off. */
55
- declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk", "approvalSuppressionAsk"];
97
+ declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "stepContextRedaction", "claimedInputRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk", "approvalSuppressionAsk"];
56
98
  /** One toggle name a repo-local policy may name in `enable`. */
57
99
  export type EnableableToggle = typeof ENABLEABLE[number];
58
100
  /** Payload version this package writes and accepts for repo-local policy files. */
@@ -70,9 +112,21 @@ export interface ResolvedPolicy {
70
112
  readonly extraEgressTools: ReadonlySet<string>;
71
113
  readonly syncRules: readonly SyncRule[];
72
114
  readonly maxScanBytes: number;
115
+ readonly aggressiveness: Aggressiveness;
116
+ /**
117
+ * Whether a message the user typed themselves is scanned like any other.
118
+ *
119
+ * Set by the level alone, at `high` only, and by nothing else: there is no
120
+ * toggle for it and a repo-local policy cannot reach it. `isUserTyped` still
121
+ * decides which messages this applies to; this decides whether that answer
122
+ * exempts them.
123
+ */
124
+ readonly userTypedInputRedaction: boolean;
73
125
  readonly breadthTier: boolean;
74
126
  readonly resultRedaction: boolean;
75
127
  readonly telemetryRedaction: boolean;
128
+ readonly stepContextRedaction: boolean;
129
+ readonly claimedInputRedaction: boolean;
76
130
  readonly remoteImageNeutralization: boolean;
77
131
  readonly redactTelemetryWorkspacePaths: boolean;
78
132
  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,106 @@
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, but it is not unconditional: it applies at `aggressiveness: low`
23
+ * and `medium`, and stops applying at `high`. This plugin cannot know where
24
+ * the request is going, and a card number a person typed on purpose is still
25
+ * cardholder data at whatever provider the deployment happens to be pointed
26
+ * at, so the deployment says which reading it wants rather than this module
27
+ * assuming one.
28
+ *
29
+ * A claimed message differs from an added one in exactly one way, and it is
30
+ * narrower than it looks. Its delivery was already recorded as
31
+ * `agent/inbox/spliced` before this waterfall ran, and no seam can rewrite a
32
+ * committed event. That event is not a surface event — `SurfaceEventType` is
33
+ * `user/message | assistant/message | tool/result` — so it derives no model
34
+ * message and no request is built from it. The copy the model reads is the
35
+ * `user/message` the loop appends after this waterfall, which is the copy this
36
+ * pass rewrites. "Model-visible ⟺ logged" therefore still holds; what the
37
+ * delivery record keeps is the original text of an untrusted delivery, which
38
+ * is evidence rather than a disagreement. ADR §30 states that asymmetry, and
39
+ * `tests/e2e/step-context.e2e.ts` pins it.
40
+ *
41
+ * Best-effort, like every seam here that is not `ctx.tools.guard()`: this
42
+ * listener registers with `{ prepend: true }` so it sees what the rest of the
43
+ * waterfall settled on, and a listener registering later with the same option
44
+ * lands ahead of it again.
45
+ * @module dsh-dlp/steps
46
+ */
47
+ import type { PreStepDecision } from '@deepseek-ai/dsh-agent';
48
+ import type { UserMessage } from '@deepseek-ai/dsh-llm';
49
+ import type { ResolvedPolicy } from './policy.ts';
50
+ import { type RedactedSpan, type SpanHasher } from './redaction.ts';
51
+ /** What one pass over a step's entering messages produced. */
52
+ export interface StepRedaction {
53
+ /** The decision to return: the same object when nothing was replaced. */
54
+ readonly decision: PreStepDecision;
55
+ /** The regions replaced, described by rule identity, offsets and keyed hash only. */
56
+ readonly spans: readonly RedactedSpan[];
57
+ /** Set when the scanned text exceeded `maxScanBytes` and tier 2 saw less than all of it. */
58
+ readonly truncatedScan: boolean;
59
+ /** Runs of each invisible-character class the scanned messages carried, by rule id. */
60
+ readonly indicators: Readonly<Record<string, number>>;
61
+ /**
62
+ * The distinct `source.kind` of every inbox-claimed message this pass
63
+ * scanned, in first-seen order. Empty when the pass only covered messages
64
+ * the waterfall added. An operator reading the sink needs this to tell a
65
+ * redacted workspace instruction from a redacted third-party delivery.
66
+ */
67
+ readonly claimedSources: readonly string[];
68
+ }
69
+ /**
70
+ * Whether one message the loop claimed from the inbox is the user's own
71
+ * typing, which exempts it below `aggressiveness: high`.
72
+ *
73
+ * `MessageSourceMap` is merge-extensible and every producer picks its own
74
+ * `kind`, so the exemption is a single allowed value rather than a list of
75
+ * denied ones: a source kind added by a package this plugin has never heard of
76
+ * is redacted rather than trusted. In the installed harness `kind: 'user'` is
77
+ * what the interactive entry points supply — `dsh-headless` for a CLI task,
78
+ * `dsh-acp` for an ACP prompt, `dsh-sdk-jsonrpc-server` for an SDK one, and
79
+ * `dsh-api-session-controller`'s `user-rpc` source for a browser prompt, which
80
+ * adds `rpcId` beside the same `kind`. `dsh-webhook`'s deliveries carry
81
+ * `kind: 'webhook'`, and the harness's own webhook invariant discriminates on
82
+ * exactly that value.
83
+ *
84
+ * Two producers borrow the value for text a person did not type: `dsh-subagent`
85
+ * and `dsh-subagent-in-process-driver` open a child agent with the parent's
86
+ * prompt under `kind: 'user'`. Those stay exempt here. Distinguishing them
87
+ * needs a fact the source does not carry, and inventing one would put a
88
+ * guessed value in a security decision.
89
+ *
90
+ * At `aggressiveness: high` the caller stops consulting this, so the two
91
+ * borrowed cases stop being exempt with everything else.
92
+ * @param message - one message the loop claimed from the inbox.
93
+ * @returns whether the message came from a person typing into their own prompt.
94
+ */
95
+ export declare function isUserTyped(message: UserMessage): boolean;
96
+ /**
97
+ * Redact the messages entering one step.
98
+ *
99
+ * @param decision - what the rest of the waterfall settled on.
100
+ * @param claimed - the messages the loop claimed from the inbox.
101
+ * @param policy - the effective policy after the tighten-only merge.
102
+ * @param hasher - mints each span's keyed hash.
103
+ * @returns the decision to return, plus what the pass found.
104
+ */
105
+ export declare function redactStepContext(decision: PreStepDecision, claimed: readonly UserMessage[], policy: ResolvedPolicy, hasher: SpanHasher): Promise<StepRedaction>;
106
+ //# sourceMappingURL=steps.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.8.2",
3
+ "version": "0.10.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",