dsh-dlp 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,9 @@ built as an out-of-repo plugin.
26
26
  credential.
27
27
  7. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
28
28
  `approval_mode: auto`, an `apply` whose approval is still pending. Both `ask` tiers are
29
- prompts rather than controls: they live at `tools/pre-execute` and can be neutralised.
29
+ prompts rather than controls: they live at `tools/pre-execute`, they can be neutralised, and
30
+ they abstain wherever the approval seam prompts nobody — which includes every install under
31
+ `DSH_PERMISSION_MODE=danger-full-access` and a stock headless install under any mode.
30
32
  8. **Writes an audit record for every decision.** A redaction or denial names the rule, its
31
33
  version, the offsets and a keyed hash; the three kinds with no matched region to describe —
32
34
  an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
@@ -46,16 +48,18 @@ Three limits worth knowing before you rely on it:
46
48
 
47
49
  - **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
48
50
  registered ahead of ours. `ctx.tools.guard()` is order-independent only because it has no allow
49
- arm.
51
+ arm. Result redaction registers with `{ prepend: true }` so it gets the last word over
52
+ listeners already registered — but a listener registering after it with the same option lands
53
+ ahead of it again.
50
54
  - **The shell-command arm is advisory pattern-matching.** It tests the whole command line and
51
55
  each of its tokens, so a credential path left *spelled* in the command is caught whatever
52
56
  program would open it: `python3 -c "open('~/.ssh/id_rsa')"` is denied. Changing the spelling
53
57
  defeats it — one glob character, quote-splitting, `find -exec`, a substitution that assembles
54
58
  the path from pieces, a base64 round-trip, each verified. **Do not count this arm as a
55
59
  control.**
56
- - **Detection is pattern-based.** No entropy rule (measured, not assumed: at a false-positive-free
57
- threshold the miss rate is 100% below 22 characters). Encoded forms pass. A homoglyph defeats
58
- every rule in this package.
60
+ - **Detection is pattern-based.** No entropy rule (measured, not assumed: the lowest
61
+ false-positive-free threshold cannot flag anything shorter than 64–66 characters). Encoded
62
+ forms pass. A homoglyph defeats every rule in this package.
59
63
 
60
64
  [The full list of limits →](https://charlotten7.github.io/dsh-dlp/)
61
65
 
@@ -70,8 +74,10 @@ dsh plugin --profile <name> add dsh-dlp
70
74
  dsh --profile <name> --dump-config # the dsh-dlp row should appear
71
75
  ```
72
76
 
73
- Any harness from `0.1.0-rc.6` onwards in the `0.1.x` line works: the peer ranges accept it and CI
74
- runs the end-to-end suite against every published rc in that range.
77
+ Any harness from `0.1.0-rc.6` onwards in the `0.1.x` line works, prereleases included. CI drives
78
+ the end-to-end suite against every published rc the peer ranges admit — `0.1.0-rc.6`, `rc.7`,
79
+ `rc.8`, `0.1.1-rc.1`, `0.1.1-rc.2` — and typechecks and builds against the `0.1.2` prerelease
80
+ line without running it end to end.
75
81
 
76
82
  Pin `@deepseek-ai/dsh-headless` explicitly — its npm `latest` tag still points at `0.0.1-rc.1`.
77
83
  The package ships a `cordis.patch.yml` bundle layer, so listing it in `dsh.profile.bundles` mounts
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Whether an `ask` this plugin returns can still reach a human.
3
+ *
4
+ * The `ask` tier is documented as a prompt and never a block: its rules have a
5
+ * real false-positive rate, so a developer who is asked about `CLAUDE.md` or
6
+ * `.github/workflows/**` says yes and carries on. The tool registry resolves an
7
+ * `ask` through `ctx.get('approval')`, and three states of that seam turn the
8
+ * prompt into a denial nobody ever saw:
9
+ *
10
+ * 1. **No service composed.** `ToolRegistry.serviceAsk` keeps the historical
11
+ * degrade to `deny` when `ctx.get('approval')` is `undefined`.
12
+ * 2. **The policy in force is `'never'`.** `ApprovalService.decide` resolves
13
+ * `'rejected'` before any dispatch — its own JSDoc calls this "never prompt
14
+ * anyone". The shipped `dsh-base` bundle selects it for every install under
15
+ * `DSH_PERMISSION_MODE=danger-full-access`, which is the unattended posture.
16
+ * 3. **Nothing composed on `approval/request`.** The waterfall falls through to
17
+ * the fail-closed `'unavailable'`, which the registry maps to `deny`. The
18
+ * shipped `dsh-headless` bundle composes no answerer, so this is the state
19
+ * of a stock headless install under every other permission mode.
20
+ *
21
+ * In all three the call is stopped with no human involved, which is exactly
22
+ * what this tier was designed not to do. Each is decided here so the caller can
23
+ * abstain instead — the same thing it already did for (1) alone.
24
+ *
25
+ * **Every check reports "reachable" when it cannot tell.** Abstaining removes a
26
+ * prompt, so an unreadable service, a missing session, or a `ctx.get` that
27
+ * returned something other than the service read here all keep the ask. Only a
28
+ * positive reading of one of the three states above abstains.
29
+ * @module dsh-dlp/approval-reach
30
+ */
31
+ /** The event `ApprovalService.decide` dispatches its answerer waterfall on. */
32
+ const APPROVAL_REQUEST = 'approval/request';
33
+ /**
34
+ * Whether every ask for this session resolves without prompting anyone.
35
+ *
36
+ * `'never'` is the only policy whose outcome is knowable without asking, and
37
+ * the service decides it before dispatch, so no composed answerer can change
38
+ * it. Anything else — including a value this build does not recognise — leaves
39
+ * the answerers to decide and is therefore not a positive reading.
40
+ * @param approval - whatever `ctx.get('approval')` returned.
41
+ * @param session - the calling agent's session, or `undefined` when the call has no agent.
42
+ * @returns `true` only when the policy in force was read and is `'never'`.
43
+ */
44
+ function policyIsNever(approval, session) {
45
+ // Without a session there is no override to fold, and the configured default
46
+ // alone cannot tell a session that never switched from one that switched to
47
+ // 'ask' — so an agent-less call keeps its prompt.
48
+ if (session === undefined)
49
+ return false;
50
+ const service = approval;
51
+ let policy;
52
+ try {
53
+ policy = service.overrideOf === undefined ? undefined : service.overrideOf(session) ?? service.config?.policy;
54
+ }
55
+ catch {
56
+ // Only a service without the members ApprovalSurface describes reaches
57
+ // here. It answers nothing about whether a human can be asked, and
58
+ // this runs inside a live tool call: a diagnostic read must not fail it.
59
+ return false;
60
+ }
61
+ return policy === 'never';
62
+ }
63
+ /**
64
+ * How many listeners are composed on the answerer waterfall.
65
+ *
66
+ * The count is read across the whole context tree, before the scope filter
67
+ * `ApprovalService.decide` applies. That over-counts — an agent-scoped answerer
68
+ * belonging to a different agent is included — and over-counting is the safe
69
+ * direction here, because only a count of zero abstains and zero listeners
70
+ * cannot be filtered into some.
71
+ *
72
+ * `EventsService._hooks` is a declared public field of the exported class, so a
73
+ * Cordis that stops carrying it fails `typecheck` and `build` in this package
74
+ * rather than being misread at a user's install; `@deepseek-ai/cordis` is
75
+ * pinned to one exact version (ADR §19) for the same reason.
76
+ * @param ctx - the plugin's context; every context in one tree shares the bus.
77
+ * @returns the number of composed answerers.
78
+ */
79
+ function composedAnswerers(ctx) {
80
+ return ctx.events._hooks[APPROVAL_REQUEST]?.length ?? 0;
81
+ }
82
+ /**
83
+ * Decide whether an ask would reach a human, at the moment of the decision.
84
+ *
85
+ * Evaluated per call rather than at mount: a session switches policy mid-run
86
+ * through `approval/policy`, the override is per session, and an answerer can
87
+ * be composed or disposed while the harness runs. A mount-time answer would be
88
+ * a cached guess at all three.
89
+ * @param ctx - the plugin's context, for the service and the event bus.
90
+ * @param session - the calling agent's session, or `undefined` when the call has no agent.
91
+ * @returns whether to ask, or which state stopped the ask from reaching anyone.
92
+ */
93
+ export function askReach(ctx, session) {
94
+ const approval = ctx.get('approval');
95
+ if (approval === undefined)
96
+ return { kind: 'unreachable', cause: 'no-service' };
97
+ // Policy first, matching the service's own order: it decides 'never' before
98
+ // dispatching, so that is the state an operator sees reported.
99
+ if (policyIsNever(approval, session))
100
+ return { kind: 'unreachable', cause: 'policy-never' };
101
+ if (composedAnswerers(ctx) === 0)
102
+ return { kind: 'unreachable', cause: 'no-answerer' };
103
+ return { kind: 'reachable' };
104
+ }
105
+ /** What abstaining does, said once and identically for all three states. */
106
+ const CONSEQUENCE = 'This tier abstains rather than becoming the silent hard deny it was designed not to be: a'
107
+ + ' write to a behaviour-changing config path, and a call that switches its own confirmation off, are allowed'
108
+ + ' through with no prompt, and each one is recorded in the audit sink as "pre-execute-ask-abstained". The'
109
+ + ' guard floor is unaffected. Set configWriteAsk: false and approvalSuppressionAsk: false to turn this tier'
110
+ + ' off entirely.';
111
+ /**
112
+ * What to tell the operator when the ask tier has nowhere to ask.
113
+ *
114
+ * Each line names the state, what to change to get the prompt back, and the
115
+ * consequence. Reported on `process.stderr` as well as `ctx.logger` for the
116
+ * reason ADR §7 records: the logger's default exporter is an in-memory ring
117
+ * buffer and no shipped bundle mounts a console exporter.
118
+ * @param cause - the state {@link askReach} read.
119
+ * @returns the whole line to report.
120
+ */
121
+ export function approvalSeamNotice(cause) {
122
+ const prefix = 'dsh-dlp: the ask tier (configWriteAsk, approvalSuppressionAsk) is enabled, but';
123
+ switch (cause) {
124
+ case 'no-service':
125
+ return `${prefix} no approval service is mounted, so the tool registry would resolve an ask as a denial`
126
+ + ' with nothing shown to anyone. Composing an approval service and an answerer puts the prompt back.'
127
+ + ` ${CONSEQUENCE}`;
128
+ case 'policy-never':
129
+ return `${prefix} the approval policy in force is "never", which resolves every ask as rejected without`
130
+ + ' prompting anyone. DSH_PERMISSION_MODE=danger-full-access selects that policy in the shipped dsh-base'
131
+ + ' bundle; run under another permission mode, or set the approval row\'s policy to "ask", for these'
132
+ + ` calls to be asked about. ${CONSEQUENCE}`;
133
+ case 'no-answerer':
134
+ return `${prefix} nothing is composed on the approval/request waterfall, which fails every ask closed as`
135
+ + ' unavailable and denies the call with nothing shown to anyone. The shipped dsh-headless bundle'
136
+ + ' composes no answerer; a surface that answers approvals, such as the Host API proxy or the ACP'
137
+ + ` bridge, puts the prompt back. ${CONSEQUENCE}`;
138
+ /* v8 ignore next 4 -- unreachable while `AskUnreachable` stays closed; the arm exists so adding a variant fails the build. */
139
+ default: {
140
+ const unhandled = cause;
141
+ throw new TypeError(`dsh-dlp: unhandled ask-tier state ${JSON.stringify(unhandled)}`);
142
+ }
143
+ }
144
+ }
package/lib/cli.js CHANGED
@@ -74,6 +74,7 @@ export function parseRecord(line) {
74
74
  return undefined;
75
75
  const tool = stringField(record, 'tool');
76
76
  const sessionId = stringField(record, 'sessionId');
77
+ const askUnreachable = stringField(record, 'askUnreachable');
77
78
  return {
78
79
  time,
79
80
  kind,
@@ -81,6 +82,7 @@ export function parseRecord(line) {
81
82
  ...sessionId === undefined ? {} : { sessionId },
82
83
  ruleIds: ruleIdsOf(record),
83
84
  unicode: unicodeOf(record),
85
+ ...askUnreachable === undefined ? {} : { askUnreachable },
84
86
  };
85
87
  }
86
88
  /** Text printed for `--help` and alongside a usage error. */
@@ -255,12 +257,17 @@ export function formatReport(records, unreadable, options) {
255
257
  lines.push(` ${unreadable} line(s) were not readable as records`);
256
258
  if (selected.length === 0)
257
259
  return lines;
258
- lines.push(...section('by decision', tally(selected, record => [record.kind])), ...section('by rule', tally(selected, record => record.ruleIds)), ...section('by tool', tally(selected, record => record.tool === undefined ? [] : [record.tool])), ...section('results carrying invisible characters', tally(selected, record => Object.keys(record.unicode))));
260
+ lines.push(...section('by decision', tally(selected, record => [record.kind])), ...section('by rule', tally(selected, record => record.ruleIds)), ...section('by tool', tally(selected, record => record.tool === undefined ? [] : [record.tool])), ...section('results carrying invisible characters', tally(selected, record => Object.keys(record.unicode))),
261
+ // The state behind an abstention, which nothing else in the report shows.
262
+ // An abstention is the one outcome where a documented prompt did not
263
+ // happen and the call ran anyway, so the operator needs the state by name.
264
+ ...section('asks that reached nobody', tally(selected, record => record.askUnreachable === undefined ? [] : [record.askUnreachable])));
259
265
  const recent = [...selected].sort((left, right) => right.time - left.time).slice(0, RECENT_LIMIT);
260
266
  lines.push('', `most recent ${recent.length}`);
261
267
  for (const record of recent) {
262
268
  const rules = record.ruleIds.length === 0 ? '-' : record.ruleIds.join(', ');
263
- lines.push(` ${new Date(record.time).toISOString()} ${record.kind} ${record.tool ?? '-'} ${rules}`);
269
+ const abstention = record.askUnreachable === undefined ? '' : ` no prompt: ${record.askUnreachable}`;
270
+ lines.push(` ${new Date(record.time).toISOString()} ${record.kind} ${record.tool ?? '-'} ${rules}${abstention}`);
264
271
  }
265
272
  return lines;
266
273
  }
package/lib/detectors.js CHANGED
@@ -45,7 +45,23 @@ export const DENY_SEVERITY = 'high';
45
45
  export const SYNC_RULES = [
46
46
  { id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
47
47
  { id: 'dsh-dlp/aws-secret-access-key', version: 1, severity: 'critical', pattern: /\baws_secret_access_key\b\s*[=:]\s*["']?[A-Za-z0-9/+=]{40}["']?/gi },
48
- { id: 'dsh-dlp/github-token', version: 1, severity: 'critical', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,251}|github_pat_[A-Za-z0-9_]{22,251})\b/g },
48
+ // Three formats, and the stateless one is first because the opaque
49
+ // alternative would otherwise claim its `ghs_` prefix and stop at the app
50
+ // id. GitHub's 2026-04-24 changelog gives the shape as `ghs_APPID_JWT`,
51
+ // "~520 characters" carrying two dots, rolled out from 2026-04-27 to late
52
+ // June; the opaque 40-character format stays because existing tokens
53
+ // "continue to work until they expire". GitHub's own suggested
54
+ // `ghs_[A-Za-z0-9.\-_]{36,}` is deliberately not what this uses: on a floor
55
+ // rule a false positive is a denial, and that class matches an ordinary
56
+ // dotted file name — `ghs_report-2026-04-24.summary-eu-west-1.json` is 40
57
+ // characters past the prefix. Anchoring on the JWT the changelog describes
58
+ // costs nothing a real token has.
59
+ {
60
+ id: 'dsh-dlp/github-token',
61
+ version: 2,
62
+ severity: 'critical',
63
+ pattern: /\b(?:ghs_[A-Za-z0-9]{1,32}_eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])|gh[pousr]_[A-Za-z0-9]{36,251}\b|github_pat_[A-Za-z0-9_]{22,251}\b)/g,
64
+ },
49
65
  { id: 'dsh-dlp/slack-token', version: 1, severity: 'critical', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
50
66
  { id: 'dsh-dlp/stripe-secret-key', version: 1, severity: 'critical', pattern: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/g },
51
67
  { id: 'dsh-dlp/anthropic-api-key', version: 1, severity: 'critical', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
@@ -89,7 +105,19 @@ export const SYNC_RULES = [
89
105
  { id: 'dsh-dlp/cloudflare-api-token', version: 1, severity: 'critical', pattern: /\bcf(?:ut|at|k)_[A-Za-z0-9_-]{40,}/g },
90
106
  { id: 'dsh-dlp/notion-token', version: 1, severity: 'critical', pattern: /\bntn_[A-Za-z0-9]{40,}/g },
91
107
  { id: 'dsh-dlp/private-key-block', version: 1, severity: 'critical', pattern: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g },
92
- { id: 'dsh-dlp/json-web-token', version: 1, severity: 'high', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
108
+ // Anchored on the base64url alphabet rather than on word characters. `\b`
109
+ // was wrong in both directions: `_` is a word character, so a JWT behind any
110
+ // `prefix_` was invisible, and a signature's trailing `-` was trimmed off the
111
+ // reported span. A letter or digit before `eyJ` still refuses, which is what
112
+ // keeps a match from starting in the middle of a longer token; `_` and `-`
113
+ // are base64url characters but are separators far more often, and the two
114
+ // literal dots this pattern requires are in no base64url run.
115
+ {
116
+ id: 'dsh-dlp/json-web-token',
117
+ version: 2,
118
+ severity: 'high',
119
+ pattern: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])/g,
120
+ },
93
121
  { id: 'dsh-dlp/credential-url', version: 1, severity: 'high', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]+@[^\s/]+/gi },
94
122
  // Webhook URLs are bearer credentials whose path segment is the secret. They
95
123
  // are in tier 1 rather than left to secretlint because the telemetry seam is
package/lib/index.js CHANGED
@@ -13,10 +13,12 @@
13
13
  * config paths and for calls carrying an argument that switches their own
14
14
  * confirmation off. Deliberately here rather than on the floor: its rules
15
15
  * have a real false-positive rate and the floor cannot ask. Neutralizable,
16
- * and it abstains entirely when no approval service is mounted.
16
+ * and it abstains entirely wherever the approval seam cannot prompt anyone.
17
17
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
18
18
  * session event is appended, so the durable log records the redacted copy;
19
19
  * a result that cannot be cleaned is withheld rather than accepted.
20
+ * Prepended, so it redacts what the rest of the waterfall returned; a
21
+ * listener registering later with the same option still runs ahead of it.
20
22
  * 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
21
23
  * reaching tier 1 only because the waterfall is synchronous.
22
24
  * 5. `llm/stream` — neutralising remote markdown image destinations in
@@ -43,6 +45,7 @@ import { neutralizeImageStream } from "./images.js";
43
45
  import { ExecutionSnapshots, mutationReason } from "./mutation.js";
44
46
  import { evaluateConfigWrite } from "./config-writes.js";
45
47
  import { evaluateApprovalSuppression } from "./approvals.js";
48
+ import { approvalSeamNotice, askReach } from "./approval-reach.js";
46
49
  import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
47
50
  import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
48
51
  import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
@@ -238,25 +241,20 @@ export function apply(ctx, config) {
238
241
  return verdict.reason;
239
242
  }), 'dsh-dlp guard floor');
240
243
  /**
241
- * Report once that the `ask` tier has nowhere to ask.
244
+ * Report once per state that the `ask` tier has nowhere to ask.
242
245
  *
243
- * The registry resolves an `ask` through `ctx.get('approval')` and keeps the
244
- * historical degrade to *deny* when no service is composed. This tier exists
245
- * because its rules are too false-positive-prone for a deny, so under a
246
- * deployment with no approval channel it abstains instead of becoming the
247
- * silent hard deny it was designed not to be. Evaluated at decision time
248
- * rather than at mount, because by then the harness is running and an absent
249
- * service is conclusive rather than a load order.
246
+ * Latched per state rather than once overall: the three states have
247
+ * different fixes, a session can move between them mid-run by switching its
248
+ * approval policy, and a single latch would leave the first one reported
249
+ * standing for a different one afterwards. `approval-reach.ts` records why
250
+ * each is a state in which an ask reaches nobody.
250
251
  */
251
- let approvalSeamReported = false;
252
- const discloseApprovalSeam = () => {
253
- if (approvalSeamReported)
252
+ const approvalSeamReported = new Set();
253
+ const discloseApprovalSeam = (cause) => {
254
+ if (approvalSeamReported.has(cause))
254
255
  return;
255
- approvalSeamReported = true;
256
- notice(ctx, 'dsh-dlp: the ask tier (configWriteAsk, approvalSuppressionAsk) is enabled, but no approval service'
257
- + ' is mounted, so an ask would degrade to a denial. This tier abstains instead: a write to a'
258
- + ' behaviour-changing config path, and a call that switches its own confirmation off, are allowed through'
259
- + ' with no prompt. The guard floor is unaffected.');
256
+ approvalSeamReported.add(cause);
257
+ notice(ctx, approvalSeamNotice(cause));
260
258
  };
261
259
  if (policy.configWriteAsk || policy.approvalSuppressionAsk) {
262
260
  // Registered ahead of the breadth tier, so a call that is both a config
@@ -280,8 +278,25 @@ export function apply(ctx, config) {
280
278
  // and would file the decision as an ask rather than as a guard denial.
281
279
  if (safeEvaluateGuard(exec, policy, hasher) !== undefined)
282
280
  return decision;
283
- if (ctx.get('approval') === undefined) {
284
- discloseApprovalSeam();
281
+ // Asked at decision time, never at mount: the session's policy is a fold
282
+ // over its own log and can change mid-run, and an answerer can be
283
+ // composed or disposed while the harness runs.
284
+ const reach = askReach(ctx, exec.agent?.session);
285
+ if (reach.kind === 'unreachable') {
286
+ discloseApprovalSeam(reach.cause);
287
+ // An abstention allowed a call this tier would have asked about, so it
288
+ // is recorded rather than left silent. Its own kind, not a flag on
289
+ // `pre-execute-ask`: `dsh-dlp report` counts by kind, and an ask that
290
+ // reached nobody must not be counted as a prompt that happened.
291
+ sink.write({
292
+ v: RECORD_VERSION,
293
+ time: new Date().toISOString(),
294
+ kind: 'pre-execute-ask-abstained',
295
+ decisionId: newDecisionId(),
296
+ ...identity(exec),
297
+ ruleId: finding.rule.id,
298
+ askUnreachable: reach.cause,
299
+ });
285
300
  return decision;
286
301
  }
287
302
  sink.write({
@@ -315,6 +330,11 @@ export function apply(ctx, config) {
315
330
  });
316
331
  }
317
332
  if (policy.resultRedaction) {
333
+ // Prepended for the same reason as the snapshot listener above, and with
334
+ // the same limit: listeners run outermost-first, so registering ahead of
335
+ // the chain is what lets this one redact the decision the rest of the
336
+ // waterfall settled on rather than have its own replaced afterwards. A
337
+ // listener registered later with the same option still lands ahead of it.
318
338
  ctx.on('tools/post-execute', async (exec, result, next) => {
319
339
  // The live definition, so a schema the deployment's own tool declares is
320
340
  // read from the registry rather than assumed. `exec.agent` is the scope
@@ -342,7 +362,7 @@ export function apply(ctx, config) {
342
362
  });
343
363
  }
344
364
  return redacted.decision;
345
- });
365
+ }, { prepend: true });
346
366
  }
347
367
  if (policy.remoteImageNeutralization) {
348
368
  ctx.on('llm/stream', (options, next) => neutralizeImageStream(next(), (host) => {
package/lib/redaction.js CHANGED
@@ -166,10 +166,37 @@ function pointerSegment(segment) {
166
166
  return segment.replace(/~/g, '~0').replace(/\//g, '~1');
167
167
  }
168
168
  /**
169
- * Redact every string inside a JSON value, at any depth.
169
+ * Replace every detected region of every string inside one structure.
170
170
  *
171
171
  * Object *keys* are left alone: a key is structure, not payload, and renaming
172
- * one would break the owning tool's `output.schema` on re-validation.
172
+ * one would break the owning tool's `output.schema` on re-validation. The
173
+ * structure is otherwise rebuilt unchanged — only string leaves differ — which
174
+ * is what lets the callers re-type the result as what they handed in.
175
+ * @param node - the structure to walk.
176
+ * @param path - JSON pointer of this node.
177
+ * @param scan - synchronous detector applied to each string.
178
+ * @param hasher - mints each span's keyed hash.
179
+ * @param spans - collects every span replaced anywhere in the walk.
180
+ * @returns the same structure with each detected region replaced.
181
+ */
182
+ function redactNode(node, path, scan, hasher, spans) {
183
+ if (typeof node === 'string') {
184
+ const redacted = redactText(node, scan(node), hasher, path);
185
+ if (redacted.spans.length === 0)
186
+ return node;
187
+ spans.push(...redacted.spans);
188
+ return redacted.text;
189
+ }
190
+ if (Array.isArray(node)) {
191
+ return node.map((item, index) => redactNode(item, `${path}/${index}`, scan, hasher, spans));
192
+ }
193
+ if (typeof node === 'object' && node !== null) {
194
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, redactNode(item, `${path}/${pointerSegment(key)}`, scan, hasher, spans)]));
195
+ }
196
+ return node;
197
+ }
198
+ /**
199
+ * Redact every string inside a JSON value, at any depth.
173
200
  * @param value - the structure to redact.
174
201
  * @param scan - synchronous detector applied to each string.
175
202
  * @param hasher - mints each span's keyed hash.
@@ -177,26 +204,10 @@ function pointerSegment(segment) {
177
204
  */
178
205
  export function redactJson(value, scan, hasher) {
179
206
  const spans = [];
180
- let changed = false;
181
- const walk = (node, path) => {
182
- if (typeof node === 'string') {
183
- const redacted = redactText(node, scan(node), hasher, path);
184
- if (redacted.spans.length === 0)
185
- return node;
186
- changed = true;
187
- spans.push(...redacted.spans);
188
- return redacted.text;
189
- }
190
- if (Array.isArray(node)) {
191
- return node.map((item, index) => walk(item, `${path}/${index}`));
192
- }
193
- if (typeof node === 'object' && node !== null) {
194
- return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, walk(item, `${path}/${pointerSegment(key)}`)]));
195
- }
196
- return node;
197
- };
198
- const result = walk(value, '');
199
- return { value: result, spans, changed };
207
+ // `redactNode` replaces strings with strings and rebuilds everything else as
208
+ // it found it, so what comes back is the same JSON shape it was handed.
209
+ const result = redactNode(value, '', scan, hasher, spans);
210
+ return { value: result, spans, changed: spans.length > 0 };
200
211
  }
201
212
  /**
202
213
  * Redact the text blocks of one model-facing content array. Non-text blocks
@@ -205,15 +216,16 @@ export function redactJson(value, scan, hasher) {
205
216
  * @param blocks - the content blocks to redact.
206
217
  * @param scan - synchronous detector applied to each block's text.
207
218
  * @param hasher - mints each span's keyed hash.
219
+ * @param pathPrefix - JSON pointer the recorded pointers hang off, for blocks that are not the decision's own.
208
220
  * @returns the redacted blocks, the spans replaced, and whether anything changed.
209
221
  */
210
- export function redactContent(blocks, scan, hasher) {
222
+ export function redactContent(blocks, scan, hasher, pathPrefix = '') {
211
223
  const spans = [];
212
224
  let changed = false;
213
225
  const content = blocks.map((block, index) => {
214
226
  if (block.type !== 'text')
215
227
  return block;
216
- const redacted = redactText(block.text, scan(block.text), hasher, `/${index}/text`);
228
+ const redacted = redactText(block.text, scan(block.text), hasher, `${pathPrefix}/${index}/text`);
217
229
  if (redacted.spans.length === 0)
218
230
  return block;
219
231
  changed = true;
@@ -222,3 +234,50 @@ export function redactContent(blocks, scan, hasher) {
222
234
  });
223
235
  return { content, spans, changed };
224
236
  }
237
+ /**
238
+ * Source fields that say what a message *is* rather than what it says. `kind`
239
+ * selects the source arm, `form` selects the fields that arm carries, and
240
+ * `plugin` names the producer; replacing one of those would change the
241
+ * message's identity rather than redact its text.
242
+ */
243
+ const MESSAGE_SOURCE_STRUCTURE = new Set(['kind', 'form', 'plugin']);
244
+ /**
245
+ * Redact the text of the `UserMessage`s a tool decision carries.
246
+ *
247
+ * A message's payload is its model-facing `content` blocks and whatever text
248
+ * its `source` records: a `snapshot` source repeats the block text in
249
+ * `sections[].text`, a `notice` source repeats its opening in `summary`, and
250
+ * `MessageSourceMap` is merge-extensible, so a plugin's own source kind may
251
+ * carry text of its own. All of it is appended to the session log with the
252
+ * message, so redacting the blocks alone would leave a copy behind. Both
253
+ * halves are walked with the same scan and the same hasher, and identical text
254
+ * yields an identical placeholder, so the copies stay in step.
255
+ *
256
+ * `id` and `role` are left alone: the inbox addresses a message by its id.
257
+ * A message with nothing to replace is returned as the object it arrived as.
258
+ * @param messages - the contexts to redact, in the order they are attached.
259
+ * @param scan - synchronous detector applied to each string.
260
+ * @param hasher - mints each span's keyed hash.
261
+ * @param pathPrefix - JSON pointer the recorded pointers hang off.
262
+ * @returns the redacted messages, the spans replaced, and whether anything changed.
263
+ */
264
+ export function redactUserMessages(messages, scan, hasher, pathPrefix) {
265
+ const spans = [];
266
+ const redacted = messages.map((message, index) => {
267
+ const path = `${pathPrefix}/${index}`;
268
+ const before = spans.length;
269
+ const content = redactContent(message.content, scan, hasher, `${path}/content`);
270
+ spans.push(...content.spans);
271
+ // Rebuilt entry by entry so the discriminants pass through untouched;
272
+ // `redactNode` returns each remaining field as the shape it was given, so
273
+ // the result is the same source with redacted text.
274
+ const source = Object.fromEntries(Object.entries(message.source).map(([key, value]) => [
275
+ key,
276
+ MESSAGE_SOURCE_STRUCTURE.has(key)
277
+ ? value
278
+ : redactNode(value, `${path}/source/${pointerSegment(key)}`, scan, hasher, spans),
279
+ ]));
280
+ return spans.length === before ? message : { ...message, content: content.content, source };
281
+ });
282
+ return { messages: redacted, spans, changed: spans.length > 0 };
283
+ }
package/lib/results.js CHANGED
@@ -4,15 +4,18 @@
4
4
  *
5
5
  * Both are best-effort by construction. A `tools/pre-execute` listener
6
6
  * registered ahead of ours can return without calling `next()` and neutralize
7
- * the breadth tier; a `tools/post-execute` listener ahead of ours can replace a
8
- * result after we redacted it. Only `ctx.tools.guard()` is order-independent.
7
+ * the breadth tier. Result redaction registers with `{ prepend: true }`, so it
8
+ * redacts whatever the rest of the waterfall settled on rather than having its
9
+ * own decision replaced afterwards — but `prepend` unshifts, so a listener
10
+ * that registers later with the same option still lands ahead of it. Only
11
+ * `ctx.tools.guard()` is order-independent, and it cannot rewrite a result.
9
12
  * What these seams buy is breadth: they can await, so `@secretlint/core`'s
10
13
  * whole rule set applies here and not in the guard.
11
14
  * @module dsh-dlp/results
12
15
  */
13
16
  import { DENY_SEVERITY, countUnicodeIndicators, scanAll, scanSync, severityRank, } from "./detectors.js";
14
17
  import { isEgressCapable } from "./paths.js";
15
- import { nestedStrings, redactContent, redactJson } from "./redaction.js";
18
+ import { nestedStrings, redactContent, redactJson, redactUserMessages, } from "./redaction.js";
16
19
  import { redactionBreaksSchema } from "./schema.js";
17
20
  /**
18
21
  * Separator the strings of one result are rendered with before the
@@ -76,6 +79,17 @@ async function prepareScan(strings, policy) {
76
79
  function contentStrings(blocks) {
77
80
  return blocks.flatMap(block => block.type === 'text' ? [block.text] : []);
78
81
  }
82
+ /**
83
+ * Text one set of `additionalContexts` carries: the model-facing blocks, plus
84
+ * whatever text the source records beside them — a `snapshot` source repeats
85
+ * the block text in `sections[].text`, a `notice` source its opening in
86
+ * `summary`, and a plugin's own source kind may carry more.
87
+ * @param messages - the contexts attached to a decision or ferried on a result.
88
+ * @returns every string those messages would put in front of the model or into the log.
89
+ */
90
+ function messageStrings(messages) {
91
+ return messages.flatMap(message => [...contentStrings(message.content), ...nestedStrings(message.source)]);
92
+ }
79
93
  /**
80
94
  * Strings the harness keeps in the durable result when the decision does not
81
95
  * replace the canonical value.
@@ -121,8 +135,23 @@ function withheldFeedback(spans) {
121
135
  * persisted surfaces are already clean — a failed result, which has no
122
136
  * value, or a success whose secret exists only in the rendered content.
123
137
  * - `block` is the fallback when neither works: a failed result whose `meta`
124
- * carries a secret, or a value that still scans dirty after redaction.
125
- * Blocking replaces the whole result, which is the only way to drop `meta`.
138
+ * carries a secret, a value that still scans dirty after redaction, or a
139
+ * context the tool body deferred. Blocking replaces the whole result, which
140
+ * is the only way to drop either.
141
+ *
142
+ * `additionalContexts` reach both the model and the log — the loop hands each
143
+ * one to the inbox, which appends an `agent/inbox/spliced` event carrying the
144
+ * whole message — and they arrive from two places that are not equally
145
+ * reachable:
146
+ *
147
+ * - the ones this decision carries, which the returned decision owns, so they
148
+ * are redacted in place. Rewriting them is a rewrite of another listener's
149
+ * data, which is the same thing the value arm already does to a downstream
150
+ * `accept{content}`, and it costs a placeholder rather than a lost result;
151
+ * - the ones the tool body deferred, which the registry concatenates ahead of
152
+ * the decision's own on *every* accept arm. No accept can rewrite or drop
153
+ * them, so a dirty one is withheld. That is `meta`'s case exactly, and it is
154
+ * the one place scanning contexts can cost a successful result.
126
155
  *
127
156
  * Replacing the value is re-validated by the registry against the tool's
128
157
  * `output.schema`, and a schema that pins the redacted string rejects it. That
@@ -142,21 +171,30 @@ function withheldFeedback(spans) {
142
171
  * @returns the decision to return, the spans replaced, and scan completeness.
143
172
  */
144
173
  export async function redactDecision(decision, result, policy, hasher, outputSchema) {
174
+ const attached = decision.additionalContexts ?? [];
145
175
  if (decision.kind === 'block') {
146
- const prepared = await prepareScan(contentStrings(decision.feedback), policy);
176
+ // A block exposes only the blocking decision's own contexts, so the
177
+ // feedback and those are the whole surface this arm can put in front of
178
+ // the model.
179
+ const prepared = await prepareScan([...contentStrings(decision.feedback), ...messageStrings(attached)], policy);
147
180
  const redacted = redactContent(decision.feedback, prepared.scan, hasher);
181
+ const redactedContexts = redactUserMessages(attached, prepared.scan, hasher, '/additionalContexts');
148
182
  return {
149
- decision: redacted.changed ? { ...decision, feedback: redacted.content } : decision,
150
- spans: redacted.spans,
183
+ decision: redacted.changed || redactedContexts.changed
184
+ ? {
185
+ ...decision,
186
+ feedback: redacted.content,
187
+ ...redactedContexts.changed ? { additionalContexts: redactedContexts.messages } : {},
188
+ }
189
+ : decision,
190
+ spans: [...redacted.spans, ...redactedContexts.spans],
151
191
  truncatedScan: prepared.truncated,
152
192
  indicators: prepared.indicators,
153
193
  };
154
194
  }
155
195
  const replacedValue = Object.hasOwn(decision, 'value') ? decision.value : undefined;
156
196
  const replacedContent = Object.hasOwn(decision, 'content') ? decision.content : undefined;
157
- const contexts = decision.additionalContexts === undefined
158
- ? {}
159
- : { additionalContexts: decision.additionalContexts };
197
+ const deferred = result.additionalContexts ?? [];
160
198
  // The value the harness will persist: a downstream replacement when there is
161
199
  // one, otherwise the tool's own. A failed result has no value at all.
162
200
  const value = replacedValue ?? (result.isError ? undefined : result.value);
@@ -164,31 +202,39 @@ export async function redactDecision(decision, result, policy, hasher, outputSch
164
202
  const persisted = replacedValue === undefined
165
203
  ? persistedStrings(result)
166
204
  : [...nestedStrings(replacedValue), ...result.meta === undefined ? [] : nestedStrings(result.meta)];
167
- const prepared = await prepareScan([...persisted, ...visible], policy);
205
+ const prepared = await prepareScan([...persisted, ...visible, ...messageStrings(attached), ...messageStrings(deferred)], policy);
168
206
  const dirty = (strings) => strings.some(text => prepared.scan(text).length > 0);
207
+ const redactedContexts = redactUserMessages(attached, prepared.scan, hasher, '/additionalContexts');
208
+ // A clean set is passed on as the array it arrived as, so a downstream
209
+ // listener's own messages are not rebuilt for nothing.
210
+ const contexts = decision.additionalContexts === undefined
211
+ ? {}
212
+ : { additionalContexts: redactedContexts.changed ? redactedContexts.messages : decision.additionalContexts };
213
+ /** Withhold the result, keeping the redacted contexts the decision brought. */
214
+ const withhold = (spans, truncated) => ({
215
+ decision: { kind: 'block', feedback: withheldFeedback(spans), ...contexts },
216
+ spans: [...spans, ...redactedContexts.spans],
217
+ truncatedScan: truncated,
218
+ indicators: prepared.indicators,
219
+ });
220
+ // Deferred contexts ride every accept arm untouched, so a dirty one settles
221
+ // the decision before any arm is considered.
222
+ const deferredSpans = redactUserMessages(deferred, prepared.scan, hasher, '/result/additionalContexts').spans;
223
+ if (deferredSpans.length > 0)
224
+ return withhold(deferredSpans, prepared.truncated);
169
225
  if (value !== undefined && dirty(nestedStrings(value))) {
170
226
  const redacted = redactJson(value, prepared.scan, hasher);
171
227
  if (redactionBreaksSchema(outputSchema, value, redacted.value)) {
172
- return {
173
- decision: { kind: 'block', feedback: withheldFeedback(redacted.spans) },
174
- spans: redacted.spans,
175
- truncatedScan: prepared.truncated,
176
- indicators: prepared.indicators,
177
- };
228
+ return withhold(redacted.spans, prepared.truncated);
178
229
  }
179
230
  const remaining = nestedStrings(redacted.value);
180
231
  const residual = await prepareScan(remaining, policy);
181
232
  if (remaining.some(text => residual.scan(text).length > 0)) {
182
- return {
183
- decision: { kind: 'block', feedback: withheldFeedback(redacted.spans) },
184
- spans: redacted.spans,
185
- truncatedScan: prepared.truncated || residual.truncated,
186
- indicators: prepared.indicators,
187
- };
233
+ return withhold(redacted.spans, prepared.truncated || residual.truncated);
188
234
  }
189
235
  return {
190
236
  decision: { kind: 'accept', value: redacted.value, ...contexts },
191
- spans: redacted.spans,
237
+ spans: [...redacted.spans, ...redactedContexts.spans],
192
238
  truncatedScan: prepared.truncated,
193
239
  indicators: prepared.indicators,
194
240
  };
@@ -196,22 +242,21 @@ export async function redactDecision(decision, result, policy, hasher, outputSch
196
242
  // The value is clean, so the durable result is clean unless `meta` — which
197
243
  // no accept arm can rewrite — carries something of its own.
198
244
  if (result.meta !== undefined && dirty(nestedStrings(result.meta))) {
199
- const spans = redactJson(result.meta, prepared.scan, hasher).spans;
200
- return {
201
- decision: { kind: 'block', feedback: withheldFeedback(spans) },
202
- spans,
203
- truncatedScan: prepared.truncated,
204
- indicators: prepared.indicators,
205
- };
245
+ return withhold(redactJson(result.meta, prepared.scan, hasher).spans, prepared.truncated);
206
246
  }
207
247
  const blocks = replacedContent ?? result.content;
208
248
  const redacted = redactContent(blocks, prepared.scan, hasher);
209
249
  if (!redacted.changed) {
210
- return { decision, spans: [], truncatedScan: prepared.truncated, indicators: prepared.indicators };
250
+ return {
251
+ decision: redactedContexts.changed ? { ...decision, ...contexts } : decision,
252
+ spans: redactedContexts.spans,
253
+ truncatedScan: prepared.truncated,
254
+ indicators: prepared.indicators,
255
+ };
211
256
  }
212
257
  return {
213
258
  decision: { kind: 'accept', content: redacted.content, ...contexts },
214
- spans: redacted.spans,
259
+ spans: [...redacted.spans, ...redactedContexts.spans],
215
260
  truncatedScan: prepared.truncated,
216
261
  indicators: prepared.indicators,
217
262
  };
package/lib/sink.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * `callId`, and a producer-minted `decisionId`.
15
15
  * @module dsh-dlp/sink
16
16
  */
17
- import { appendFileSync } from 'node:fs';
17
+ import { appendFileSync, chmodSync } from 'node:fs';
18
18
  import { randomUUID } from 'node:crypto';
19
19
  import { stripControlSequences } from "./detectors.js";
20
20
  /**
@@ -26,6 +26,15 @@ export function newDecisionId() {
26
26
  }
27
27
  /** Payload version carried inside every record this plugin writes. */
28
28
  export const RECORD_VERSION = 1;
29
+ /**
30
+ * Mode the sink file is kept at.
31
+ *
32
+ * The records hold rule ids, keyed hashes, tool names and call identity — no
33
+ * matched value ever reaches them — but they are the evidence that a decision
34
+ * happened, so they are readable by the operator's group and never by the
35
+ * world. This is the mode the sibling packages keep their spools at.
36
+ */
37
+ export const AUDIT_MODE = 0o640;
29
38
  /**
30
39
  * One record with every string cleaned of terminal control sequences.
31
40
  *
@@ -68,12 +77,20 @@ export class AuditSink {
68
77
  * evidence, not enforcement, and letting a full disk turn every tool call
69
78
  * into a denial trades a confidentiality control for an availability
70
79
  * outage. A guard that throws would also skip `tools/post-execute` and so
71
- * disable redaction for that call.
80
+ * disable redaction for that call. A failure to hold
81
+ * {@link AUDIT_MODE} is reported on the same terms: the record is written
82
+ * either way, and an operator who cannot see the mode cannot know who else
83
+ * can read the file.
72
84
  * @param record - the decision to record.
73
85
  */
74
86
  write(record) {
75
87
  try {
76
- appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`);
88
+ appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`, { mode: AUDIT_MODE });
89
+ // `appendFileSync`'s `mode` applies only when the call creates the file,
90
+ // and even then the umask masks it, so the mode is forced afterwards.
91
+ // Forcing it on every append also takes back a loosening applied to an
92
+ // existing sink.
93
+ chmodSync(this.#path, AUDIT_MODE);
77
94
  }
78
95
  catch (error) {
79
96
  this.#onFailure(error);
package/lib/telemetry.js CHANGED
@@ -26,7 +26,7 @@
26
26
  * @module dsh-dlp/telemetry
27
27
  */
28
28
  import { scanSync } from "./detectors.js";
29
- import { placeholderFor, redactJson, redactText } from "./redaction.js";
29
+ import { placeholderFor, redactJson, redactText, } from "./redaction.js";
30
30
  /**
31
31
  * What to tell the operator when the redaction seam will never dispatch.
32
32
  *
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Whether an `ask` this plugin returns can still reach a human.
3
+ *
4
+ * The `ask` tier is documented as a prompt and never a block: its rules have a
5
+ * real false-positive rate, so a developer who is asked about `CLAUDE.md` or
6
+ * `.github/workflows/**` says yes and carries on. The tool registry resolves an
7
+ * `ask` through `ctx.get('approval')`, and three states of that seam turn the
8
+ * prompt into a denial nobody ever saw:
9
+ *
10
+ * 1. **No service composed.** `ToolRegistry.serviceAsk` keeps the historical
11
+ * degrade to `deny` when `ctx.get('approval')` is `undefined`.
12
+ * 2. **The policy in force is `'never'`.** `ApprovalService.decide` resolves
13
+ * `'rejected'` before any dispatch — its own JSDoc calls this "never prompt
14
+ * anyone". The shipped `dsh-base` bundle selects it for every install under
15
+ * `DSH_PERMISSION_MODE=danger-full-access`, which is the unattended posture.
16
+ * 3. **Nothing composed on `approval/request`.** The waterfall falls through to
17
+ * the fail-closed `'unavailable'`, which the registry maps to `deny`. The
18
+ * shipped `dsh-headless` bundle composes no answerer, so this is the state
19
+ * of a stock headless install under every other permission mode.
20
+ *
21
+ * In all three the call is stopped with no human involved, which is exactly
22
+ * what this tier was designed not to do. Each is decided here so the caller can
23
+ * abstain instead — the same thing it already did for (1) alone.
24
+ *
25
+ * **Every check reports "reachable" when it cannot tell.** Abstaining removes a
26
+ * prompt, so an unreadable service, a missing session, or a `ctx.get` that
27
+ * returned something other than the service read here all keep the ask. Only a
28
+ * positive reading of one of the three states above abstains.
29
+ * @module dsh-dlp/approval-reach
30
+ */
31
+ import type { Context } from '@deepseek-ai/cordis';
32
+ import type { Session } from '@deepseek-ai/dsh-session';
33
+ /** Which of the three states stopped an ask from reaching a human. */
34
+ export type AskUnreachable =
35
+ /** Nothing is composed on `ctx.approval`. */
36
+ 'no-service'
37
+ /** The policy in force for this session is `'never'`. */
38
+ | 'policy-never'
39
+ /** Nothing is composed on the `approval/request` waterfall. */
40
+ | 'no-answerer';
41
+ /** Whether an ask can reach a human, and when it cannot, which state stopped it. */
42
+ export type AskReach = {
43
+ readonly kind: 'reachable';
44
+ } | {
45
+ readonly kind: 'unreachable';
46
+ readonly cause: AskUnreachable;
47
+ };
48
+ /**
49
+ * Decide whether an ask would reach a human, at the moment of the decision.
50
+ *
51
+ * Evaluated per call rather than at mount: a session switches policy mid-run
52
+ * through `approval/policy`, the override is per session, and an answerer can
53
+ * be composed or disposed while the harness runs. A mount-time answer would be
54
+ * a cached guess at all three.
55
+ * @param ctx - the plugin's context, for the service and the event bus.
56
+ * @param session - the calling agent's session, or `undefined` when the call has no agent.
57
+ * @returns whether to ask, or which state stopped the ask from reaching anyone.
58
+ */
59
+ export declare function askReach(ctx: Context, session: Session | undefined): AskReach;
60
+ /**
61
+ * What to tell the operator when the ask tier has nowhere to ask.
62
+ *
63
+ * Each line names the state, what to change to get the prompt back, and the
64
+ * consequence. Reported on `process.stderr` as well as `ctx.logger` for the
65
+ * reason ADR §7 records: the logger's default exporter is an in-memory ring
66
+ * buffer and no shipped bundle mounts a console exporter.
67
+ * @param cause - the state {@link askReach} read.
68
+ * @returns the whole line to report.
69
+ */
70
+ export declare function approvalSeamNotice(cause: AskUnreachable): string;
71
+ //# sourceMappingURL=approval-reach.d.ts.map
@@ -23,6 +23,12 @@ export interface ReportRecord {
23
23
  readonly ruleIds: readonly string[];
24
24
  /** Invisible-character runs by rule id. */
25
25
  readonly unicode: Readonly<Record<string, number>>;
26
+ /**
27
+ * Which state left the ask tier with nowhere to ask, on the one kind that
28
+ * records it. The abstention allowed a call the tier would have asked
29
+ * about, so this is the field an operator changes to get the prompt back.
30
+ */
31
+ readonly askUnreachable?: string;
26
32
  }
27
33
  /**
28
34
  * Parse one JSONL line into the fields this command reports on.
@@ -13,10 +13,12 @@
13
13
  * config paths and for calls carrying an argument that switches their own
14
14
  * confirmation off. Deliberately here rather than on the floor: its rules
15
15
  * have a real false-positive rate and the floor cannot ask. Neutralizable,
16
- * and it abstains entirely when no approval service is mounted.
16
+ * and it abstains entirely wherever the approval seam cannot prompt anyone.
17
17
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
18
18
  * session event is appended, so the durable log records the redacted copy;
19
19
  * a result that cannot be cleaned is withheld rather than accepted.
20
+ * Prepended, so it redacts what the rest of the waterfall returned; a
21
+ * listener registering later with the same option still runs ahead of it.
20
22
  * 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
21
23
  * reaching tier 1 only because the waterfall is synchronous.
22
24
  * 5. `llm/stream` — neutralising remote markdown image destinations in
@@ -14,9 +14,22 @@
14
14
  * installation key, useless to anyone without the key.
15
15
  * @module dsh-dlp/redaction
16
16
  */
17
- import type { ContentBlock } from '@deepseek-ai/dsh-llm';
18
- import type { JsonValue } from '@deepseek-ai/dsh-session';
17
+ import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm';
19
18
  import { type Detection, type Severity } from './detectors.ts';
19
+ /**
20
+ * A value that round-trips through JSON without loss.
21
+ *
22
+ * Declared here rather than imported. Upstream moved this alias out of
23
+ * `@deepseek-ai/dsh-session` and into `@deepseek-ai/dsh-util-values` in
24
+ * `0.1.2-alpha.2`; the new package does not exist in any release the peer
25
+ * ranges also admit, and a TypeScript import cannot name two homes, so either
26
+ * import breaks half the supported range. The alias is structural and carries
27
+ * no runtime, so a local copy is assignable in both directions wherever it
28
+ * meets upstream's — and nothing outside this package is handed one.
29
+ */
30
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
31
+ [key: string]: JsonValue;
32
+ };
20
33
  /** One replaced region, described without disclosing what it held. */
21
34
  export interface RedactedSpan {
22
35
  /** Rule that justified the replacement; the strictest one when spans merged. */
@@ -83,9 +96,6 @@ export declare function redactText(text: string, detections: readonly Detection[
83
96
  export declare function nestedStrings(value: unknown): string[];
84
97
  /**
85
98
  * Redact every string inside a JSON value, at any depth.
86
- *
87
- * Object *keys* are left alone: a key is structure, not payload, and renaming
88
- * one would break the owning tool's `output.schema` on re-validation.
89
99
  * @param value - the structure to redact.
90
100
  * @param scan - synchronous detector applied to each string.
91
101
  * @param hasher - mints each span's keyed hash.
@@ -103,11 +113,37 @@ export declare function redactJson(value: JsonValue, scan: (text: string) => rea
103
113
  * @param blocks - the content blocks to redact.
104
114
  * @param scan - synchronous detector applied to each block's text.
105
115
  * @param hasher - mints each span's keyed hash.
116
+ * @param pathPrefix - JSON pointer the recorded pointers hang off, for blocks that are not the decision's own.
106
117
  * @returns the redacted blocks, the spans replaced, and whether anything changed.
107
118
  */
108
- export declare function redactContent(blocks: readonly ContentBlock[], scan: (text: string) => readonly Detection[], hasher: SpanHasher): {
119
+ export declare function redactContent(blocks: readonly ContentBlock[], scan: (text: string) => readonly Detection[], hasher: SpanHasher, pathPrefix?: string): {
109
120
  content: ContentBlock[];
110
121
  spans: readonly RedactedSpan[];
111
122
  changed: boolean;
112
123
  };
124
+ /**
125
+ * Redact the text of the `UserMessage`s a tool decision carries.
126
+ *
127
+ * A message's payload is its model-facing `content` blocks and whatever text
128
+ * its `source` records: a `snapshot` source repeats the block text in
129
+ * `sections[].text`, a `notice` source repeats its opening in `summary`, and
130
+ * `MessageSourceMap` is merge-extensible, so a plugin's own source kind may
131
+ * carry text of its own. All of it is appended to the session log with the
132
+ * message, so redacting the blocks alone would leave a copy behind. Both
133
+ * halves are walked with the same scan and the same hasher, and identical text
134
+ * yields an identical placeholder, so the copies stay in step.
135
+ *
136
+ * `id` and `role` are left alone: the inbox addresses a message by its id.
137
+ * A message with nothing to replace is returned as the object it arrived as.
138
+ * @param messages - the contexts to redact, in the order they are attached.
139
+ * @param scan - synchronous detector applied to each string.
140
+ * @param hasher - mints each span's keyed hash.
141
+ * @param pathPrefix - JSON pointer the recorded pointers hang off.
142
+ * @returns the redacted messages, the spans replaced, and whether anything changed.
143
+ */
144
+ export declare function redactUserMessages(messages: readonly UserMessage[], scan: (text: string) => readonly Detection[], hasher: SpanHasher, pathPrefix: string): {
145
+ messages: UserMessage[];
146
+ spans: readonly RedactedSpan[];
147
+ changed: boolean;
148
+ };
113
149
  //# sourceMappingURL=redaction.d.ts.map
@@ -4,8 +4,11 @@
4
4
  *
5
5
  * Both are best-effort by construction. A `tools/pre-execute` listener
6
6
  * registered ahead of ours can return without calling `next()` and neutralize
7
- * the breadth tier; a `tools/post-execute` listener ahead of ours can replace a
8
- * result after we redacted it. Only `ctx.tools.guard()` is order-independent.
7
+ * the breadth tier. Result redaction registers with `{ prepend: true }`, so it
8
+ * redacts whatever the rest of the waterfall settled on rather than having its
9
+ * own decision replaced afterwards — but `prepend` unshifts, so a listener
10
+ * that registers later with the same option still lands ahead of it. Only
11
+ * `ctx.tools.guard()` is order-independent, and it cannot rewrite a result.
9
12
  * What these seams buy is breadth: they can await, so `@secretlint/core`'s
10
13
  * whole rule set applies here and not in the guard.
11
14
  * @module dsh-dlp/results
@@ -40,8 +43,23 @@ export interface ResultRedaction {
40
43
  * persisted surfaces are already clean — a failed result, which has no
41
44
  * value, or a success whose secret exists only in the rendered content.
42
45
  * - `block` is the fallback when neither works: a failed result whose `meta`
43
- * carries a secret, or a value that still scans dirty after redaction.
44
- * Blocking replaces the whole result, which is the only way to drop `meta`.
46
+ * carries a secret, a value that still scans dirty after redaction, or a
47
+ * context the tool body deferred. Blocking replaces the whole result, which
48
+ * is the only way to drop either.
49
+ *
50
+ * `additionalContexts` reach both the model and the log — the loop hands each
51
+ * one to the inbox, which appends an `agent/inbox/spliced` event carrying the
52
+ * whole message — and they arrive from two places that are not equally
53
+ * reachable:
54
+ *
55
+ * - the ones this decision carries, which the returned decision owns, so they
56
+ * are redacted in place. Rewriting them is a rewrite of another listener's
57
+ * data, which is the same thing the value arm already does to a downstream
58
+ * `accept{content}`, and it costs a placeholder rather than a lost result;
59
+ * - the ones the tool body deferred, which the registry concatenates ahead of
60
+ * the decision's own on *every* accept arm. No accept can rewrite or drop
61
+ * them, so a dirty one is withheld. That is `meta`'s case exactly, and it is
62
+ * the one place scanning contexts can cost a successful result.
45
63
  *
46
64
  * Replacing the value is re-validated by the registry against the tool's
47
65
  * `output.schema`, and a schema that pins the redacted string rejects it. That
@@ -14,6 +14,7 @@
14
14
  * `callId`, and a producer-minted `decisionId`.
15
15
  * @module dsh-dlp/sink
16
16
  */
17
+ import type { AskUnreachable } from './approval-reach.ts';
17
18
  import type { RedactedSpan } from './redaction.ts';
18
19
  declare const decisionIdBrand: unique symbol;
19
20
  /** Producer-minted id correlating one decision across records. */
@@ -27,8 +28,17 @@ export type DecisionId = string & {
27
28
  export declare function newDecisionId(): DecisionId;
28
29
  /** Payload version carried inside every record this plugin writes. */
29
30
  export declare const RECORD_VERSION = 1;
31
+ /**
32
+ * Mode the sink file is kept at.
33
+ *
34
+ * The records hold rule ids, keyed hashes, tool names and call identity — no
35
+ * matched value ever reaches them — but they are the evidence that a decision
36
+ * happened, so they are readable by the operator's group and never by the
37
+ * world. This is the mode the sibling packages keep their spools at.
38
+ */
39
+ export declare const AUDIT_MODE = 416;
30
40
  /** What produced one audit record. */
31
- export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | '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' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
32
42
  /** One durable record. Never carries matched secret text. */
33
43
  export interface AuditRecord {
34
44
  readonly v: number;
@@ -66,6 +76,13 @@ export interface AuditRecord {
66
76
  * behaviour-changing file, not that any part of it matched a secret.
67
77
  */
68
78
  readonly ruleId?: string;
79
+ /**
80
+ * Which state left the ask tier with nowhere to ask, for a
81
+ * `pre-execute-ask-abstained`. An abstention allowed a call the tier would
82
+ * otherwise have asked about, so the state that caused it is the field an
83
+ * operator needs to change to get the prompt back.
84
+ */
85
+ readonly askUnreachable?: AskUnreachable;
69
86
  /** Telemetry record channel, for `telemetry-redaction`. */
70
87
  readonly channel?: string;
71
88
  /** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
@@ -95,7 +112,10 @@ export declare class AuditSink {
95
112
  * evidence, not enforcement, and letting a full disk turn every tool call
96
113
  * into a denial trades a confidentiality control for an availability
97
114
  * outage. A guard that throws would also skip `tools/post-execute` and so
98
- * disable redaction for that call.
115
+ * disable redaction for that call. A failure to hold
116
+ * {@link AUDIT_MODE} is reported on the same terms: the record is written
117
+ * either way, and an operator who cannot see the mode cannot know who else
118
+ * can read the file.
99
119
  * @param record - the decision to record.
100
120
  */
101
121
  write(record: AuditRecord): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dlp",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Data-loss-prevention plugin for DeepSeek Harness: a non-configurable tool guard floor, tool-result redaction, and fail-closed telemetry redaction",
5
5
  "license": "MIT",
6
6
  "author": "Ivan Tyshchenko <nsof@protonmail.com>",
@@ -50,11 +50,11 @@
50
50
  }
51
51
  },
52
52
  "peerDependencies": {
53
- "@deepseek-ai/cordis": "4.0.1",
54
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
55
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
56
- "@deepseek-ai/dsh-session-telemetry": "^0.1.0-rc.6",
57
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
53
+ "@deepseek-ai/cordis": "^4.0.1",
54
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
55
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
56
+ "@deepseek-ai/dsh-session-telemetry": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
57
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0"
58
58
  },
59
59
  "dependencies": {
60
60
  "@deepseek-ai/schemastery": "3.18.1",
@@ -63,7 +63,7 @@
63
63
  "js-yaml": "^4.1.0"
64
64
  },
65
65
  "devDependencies": {
66
- "@deepseek-ai/cordis": "4.0.1",
66
+ "@deepseek-ai/cordis": "4.0.2",
67
67
  "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
68
68
  "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
69
69
  "@deepseek-ai/dsh-llm-mock-server": "0.1.0-rc.6",