dsh-dlp 0.2.0 → 0.4.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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dsh-dlp` — data-loss prevention for DeepSeek Harness.
3
3
  *
4
- * Four registrations, in descending order of how much they can be trusted:
4
+ * Six 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
@@ -9,11 +9,24 @@
9
9
  * has no allow arm.
10
10
  * 2. `tools/pre-execute` — the async breadth tier, which can await
11
11
  * `@secretlint/core`. Neutralizable by any listener registered ahead of it.
12
+ * 2b. `tools/pre-execute` — the `ask` tier for writes to behaviour-changing
13
+ * config paths. Deliberately here rather than on the floor: its rules have a
14
+ * real false-positive rate and the floor cannot ask. Neutralizable, and it
15
+ * abstains entirely when no approval service is mounted.
12
16
  * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
13
17
  * session event is appended, so the durable log records the redacted copy;
14
18
  * a result that cannot be cleaned is withheld rather than accepted.
15
19
  * 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
16
20
  * reaching tier 1 only because the waterfall is synchronous.
21
+ * 5. `llm/stream` — neutralising remote markdown image destinations in
22
+ * assistant output, before the text becomes a session event.
23
+ *
24
+ * Three of those registrations mitigate defects in the harness rather than in
25
+ * a deployment's own configuration: the missing Content-Security-Policy behind
26
+ * (5), the mutable execution object behind the guard's mutation check, and the
27
+ * silently inert telemetry seam behind the notice reported at mount. Each one
28
+ * is partial, none closes its channel, and README.md says so beside the
29
+ * feature.
17
30
  *
18
31
  * This plugin is not a containment boundary. It runs in-process at the agent's
19
32
  * own uid; anything the agent can execute can read the same files the guard
@@ -25,8 +38,11 @@ import { readFileSync, writeFileSync } from 'node:fs';
25
38
  import { loadRepoPolicy, resolvePolicy } from "./policy.js";
26
39
  import { SpanHasher } from "./redaction.js";
27
40
  import { safeEvaluateGuard } from "./guard.js";
41
+ import { neutralizeImageStream } from "./images.js";
42
+ import { ExecutionSnapshots, mutationReason } from "./mutation.js";
43
+ import { evaluateConfigWrite } from "./config-writes.js";
28
44
  import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
29
- import { redactRecord } from "./telemetry.js";
45
+ import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
30
46
  import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
31
47
  export { Config } from "./policy.js";
32
48
  /** Display metadata; labels the plugin in Cordis diagnostics. */
@@ -84,6 +100,16 @@ function report(ctx, message) {
84
100
  ctx.logger.error(message);
85
101
  process.stderr.write(`${message}\n`);
86
102
  }
103
+ /**
104
+ * Report something the operator should know that is not a fault, on the same
105
+ * two channels and for the same reason as {@link report}.
106
+ * @param ctx - the plugin's context, used for its logger.
107
+ * @param message - the whole line to report.
108
+ */
109
+ function notice(ctx, message) {
110
+ ctx.logger.warn(message);
111
+ process.stderr.write(`${message}\n`);
112
+ }
87
113
  /**
88
114
  * Load the repo-local policy tier, if the deployment named one.
89
115
  *
@@ -138,7 +164,30 @@ export function apply(ctx, config) {
138
164
  ...position === undefined ? {} : { turn: position.turn, step: position.step },
139
165
  };
140
166
  };
167
+ const snapshots = new ExecutionSnapshots(hasher);
168
+ /**
169
+ * Report the telemetry seam's state once.
170
+ *
171
+ * At mount only a backend that is already there answers the question: the
172
+ * backend can load after this plugin, and calling that absence "inert" would
173
+ * be a false alarm. `conclusive` marks the later call, made once the harness
174
+ * is running sessions, where an absent backend really means no dispatcher.
175
+ */
176
+ let telemetrySeamReported = false;
177
+ const discloseTelemetrySeam = (conclusive) => {
178
+ if (telemetrySeamReported)
179
+ return;
180
+ const backend = ctx.get('sessionTelemetry');
181
+ if (backend === undefined && !conclusive)
182
+ return;
183
+ telemetrySeamReported = true;
184
+ const line = telemetrySeamNotice(backend?.sharing);
185
+ if (line !== undefined)
186
+ notice(ctx, line);
187
+ };
141
188
  ctx.on('session/event', (_session, event) => {
189
+ if (policy.telemetryRedaction)
190
+ discloseTelemetrySeam(true);
142
191
  if (event.type === 'tool/call') {
143
192
  correlator.note(event.data.callId, { turn: event.data.turn, step: event.data.step });
144
193
  return;
@@ -147,9 +196,32 @@ export function apply(ctx, config) {
147
196
  correlator.forget(event.data.message.source.callId);
148
197
  }
149
198
  });
199
+ // Snapshot each call before the rest of the waterfall can rewrite it. The
200
+ // prepend is best-effort by construction: a listener registered later with
201
+ // the same option runs ahead of this one.
202
+ ctx.on('tools/pre-execute', (exec, next) => {
203
+ snapshots.record(exec);
204
+ return next();
205
+ }, { prepend: true });
150
206
  // The floor. Registered on a plain context so it applies globally: to every
151
207
  // agent, every `run_code` inner sub-call, and every subagent child.
152
208
  ctx.effect(() => ctx.tools.guard((exec) => {
209
+ // Integrity first: a call whose name or arguments changed after `tool/call`
210
+ // was appended is denied whatever the policy tables say about it, because
211
+ // the log no longer describes what would run.
212
+ const mutation = snapshots.detect(exec);
213
+ if (mutation !== undefined) {
214
+ sink.write({
215
+ v: RECORD_VERSION,
216
+ time: new Date().toISOString(),
217
+ kind: 'execution-mutation',
218
+ decisionId: newDecisionId(),
219
+ ...identity(exec),
220
+ mutatedFields: mutation.fields,
221
+ ...mutation.fields.includes('name') ? { originalTool: mutation.originalTool } : {},
222
+ });
223
+ return mutationReason(exec, mutation);
224
+ }
153
225
  const verdict = safeEvaluateGuard(exec, policy, hasher);
154
226
  if (verdict === undefined)
155
227
  return undefined;
@@ -163,6 +235,59 @@ export function apply(ctx, config) {
163
235
  });
164
236
  return verdict.reason;
165
237
  }), 'dsh-dlp guard floor');
238
+ /**
239
+ * Report once that the `ask` tier has nowhere to ask.
240
+ *
241
+ * The registry resolves an `ask` through `ctx.get('approval')` and keeps the
242
+ * historical degrade to *deny* when no service is composed. This tier exists
243
+ * because its rules are too false-positive-prone for a deny, so under a
244
+ * deployment with no approval channel it abstains instead of becoming the
245
+ * silent hard deny it was designed not to be. Evaluated at decision time
246
+ * rather than at mount, because by then the harness is running and an absent
247
+ * service is conclusive rather than a load order.
248
+ */
249
+ let approvalSeamReported = false;
250
+ const discloseApprovalSeam = () => {
251
+ if (approvalSeamReported)
252
+ return;
253
+ approvalSeamReported = true;
254
+ notice(ctx, 'dsh-dlp: configWriteAsk is enabled, but no approval service is mounted, so an ask would degrade'
255
+ + ' to a denial. This tier abstains instead: a write to a behaviour-changing config path is allowed through'
256
+ + ' with no prompt. The guard floor is unaffected.');
257
+ };
258
+ if (policy.configWriteAsk) {
259
+ // Registered ahead of the breadth tier, so a call that is both a config
260
+ // write and carries a secret is denied rather than merely asked about:
261
+ // this listener sees whatever the rest of the waterfall settled on and
262
+ // only ever narrows `allow` into `ask`.
263
+ ctx.on('tools/pre-execute', async (exec, next) => {
264
+ const decision = await next();
265
+ if (decision.kind !== 'allow')
266
+ return decision;
267
+ const finding = evaluateConfigWrite(exec);
268
+ if (finding === undefined)
269
+ return decision;
270
+ // A call the floor will deny anyway is left to the floor. Any non-allow
271
+ // decision from this waterfall skips guards entirely, so asking here
272
+ // would replace an unconditional denial with a prompt a user can grant,
273
+ // and would file the decision as an ask rather than as a guard denial.
274
+ if (safeEvaluateGuard(exec, policy, hasher) !== undefined)
275
+ return decision;
276
+ if (ctx.get('approval') === undefined) {
277
+ discloseApprovalSeam();
278
+ return decision;
279
+ }
280
+ sink.write({
281
+ v: RECORD_VERSION,
282
+ time: new Date().toISOString(),
283
+ kind: 'pre-execute-ask',
284
+ decisionId: newDecisionId(),
285
+ ...identity(exec),
286
+ ruleId: finding.rule.id,
287
+ });
288
+ return { kind: 'ask', reason: finding.reason };
289
+ });
290
+ }
166
291
  if (policy.breadthTier) {
167
292
  ctx.on('tools/pre-execute', async (exec, next) => {
168
293
  const decision = await next();
@@ -212,7 +337,20 @@ export function apply(ctx, config) {
212
337
  return redacted.decision;
213
338
  });
214
339
  }
340
+ if (policy.remoteImageNeutralization) {
341
+ ctx.on('llm/stream', (options, next) => neutralizeImageStream(next(), (host) => {
342
+ sink.write({
343
+ v: RECORD_VERSION,
344
+ time: new Date().toISOString(),
345
+ kind: 'assistant-image-neutralized',
346
+ decisionId: newDecisionId(),
347
+ ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
348
+ host,
349
+ });
350
+ }));
351
+ }
215
352
  if (policy.telemetryRedaction) {
353
+ discloseTelemetrySeam(false);
216
354
  ctx.on('session-telemetry/record', (_record, next) => {
217
355
  // Throwing here withholds this one record; the coordinator contains it
218
356
  // and the agent loop never sees the failure. That is the fail-closed
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Detecting a tool call that was rewritten after it was logged.
3
+ *
4
+ * The registry deep-freezes `exec.arguments` but does not freeze the execution
5
+ * object until results are notified, so a `tools/pre-execute` listener can
6
+ * reassign `exec.arguments` or `exec.name` — and reassigning `exec.name`
7
+ * changes which tool body runs. The agent loop appended `tool/call` from the
8
+ * model's own response block before the waterfall ran, so nothing in the
9
+ * session log records the change: the durable record then describes a
10
+ * different call than the one about to execute.
11
+ *
12
+ * This module snapshots the call as early in the waterfall as it can and
13
+ * compares in the guard, which runs after the whole waterfall and cannot be
14
+ * out-ordered. It is detection, not prevention: preventing the rewrite would
15
+ * mean freezing an object this plugin does not own, and the snapshot itself is
16
+ * best-effort — a later `{ prepend: true }` registration runs ahead of ours and
17
+ * would be snapshotted after its own rewrite.
18
+ * @module dsh-dlp/mutation
19
+ */
20
+ /**
21
+ * Render a JSON value with object keys in a fixed order, so two equal argument
22
+ * sets hash equally whatever order a listener rebuilt them in.
23
+ * @param value - the argument value; JSON-serializable by the registry's own snapshot step.
24
+ * @returns a canonical string for hashing.
25
+ */
26
+ export function canonicalJson(value) {
27
+ if (Array.isArray(value))
28
+ return `[${value.map(canonicalJson).join(',')}]`;
29
+ if (typeof value === 'object' && value !== null) {
30
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
31
+ }
32
+ // `undefined` has no JSON rendering; it reaches here only for an execution
33
+ // whose arguments never materialized, which the registry fails before the
34
+ // waterfall. A fixed token keeps the digest total either way.
35
+ return JSON.stringify(value) ?? 'undefined';
36
+ }
37
+ /**
38
+ * Remembers what each pending call looked like before the rest of the
39
+ * `tools/pre-execute` waterfall ran.
40
+ *
41
+ * Keyed by the execution object's identity in a `WeakMap`, the way the
42
+ * registry keys its own per-execution state, so the entry is found again in
43
+ * the guard and released with the execution.
44
+ */
45
+ export class ExecutionSnapshots {
46
+ #snapshots = new WeakMap();
47
+ #hasher;
48
+ /**
49
+ * @param hasher - mints the keyed digest of the arguments; the values themselves are never stored.
50
+ */
51
+ constructor(hasher) {
52
+ this.#hasher = hasher;
53
+ }
54
+ /**
55
+ * Snapshot one pending call.
56
+ * @param exec - the execution as the earliest listener sees it.
57
+ */
58
+ record(exec) {
59
+ this.#snapshots.set(exec, { name: exec.name, digest: this.#hasher.hash(canonicalJson(exec.arguments)) });
60
+ }
61
+ /**
62
+ * Compare one call against its snapshot.
63
+ *
64
+ * A call with no snapshot is not a finding: the listener may never have run
65
+ * for it, and reporting absence as mutation would deny calls this plugin
66
+ * simply did not observe.
67
+ * @param exec - the execution as the guard stage sees it.
68
+ * @returns what changed, or `undefined` when nothing did.
69
+ */
70
+ detect(exec) {
71
+ const snapshot = this.#snapshots.get(exec);
72
+ if (snapshot === undefined)
73
+ return undefined;
74
+ const fields = [];
75
+ if (exec.name !== snapshot.name)
76
+ fields.push('name');
77
+ if (this.#hasher.hash(canonicalJson(exec.arguments)) !== snapshot.digest)
78
+ fields.push('arguments');
79
+ return fields.length === 0 ? undefined : { fields, originalTool: snapshot.name };
80
+ }
81
+ }
82
+ /**
83
+ * Denial text for a rewritten call.
84
+ *
85
+ * Both tool names are named: a tool name is already in the session log and in
86
+ * every other denial this plugin writes, and naming them is the whole point —
87
+ * the operator needs to know which call the log describes and which one was
88
+ * about to run. No argument value appears.
89
+ * @param exec - the call as the guard sees it, after the rewrite.
90
+ * @param mutation - the fields that changed and the recorded tool name.
91
+ * @returns the model-facing reason.
92
+ */
93
+ export function mutationReason(exec, mutation) {
94
+ const changed = mutation.fields.join(' and ');
95
+ const renamed = mutation.fields.includes('name')
96
+ ? ` The session log records a call to ${JSON.stringify(mutation.originalTool)}.`
97
+ : '';
98
+ return `dsh-dlp denied ${JSON.stringify(exec.name)}: another mounted plugin rewrote this call's ${changed} `
99
+ + `after the session log recorded it, so the log and the presented call describe something other than what `
100
+ + `would have run.${renamed} The call is denied because a tool call that cannot be reconstructed from the log `
101
+ + 'is not auditable. This is a defect in a mounted plugin, not in the call; report it to the deployment operator.';
102
+ }
package/lib/paths.js CHANGED
@@ -48,9 +48,61 @@ export const CREDENTIAL_PATH_RULES = [
48
48
  { id: 'dsh-dlp/path-pgpass', version: 1, pattern: /(^|\/)\.pgpass$/i },
49
49
  { id: 'dsh-dlp/path-mysql-config', version: 1, pattern: /(^|\/)\.my\.cnf$/i },
50
50
  { id: 'dsh-dlp/path-service-account', version: 1, pattern: /(^|\/)[^/]*service[._-]?account[^/]*\.json$/i },
51
+ // Coding-agent credential stores. IronWorm's 44 packages and SANDWORM_MODE
52
+ // name these verbatim; an `auth.json` under an agent's own directory is a
53
+ // token file whatever else the directory holds.
54
+ { id: 'dsh-dlp/path-agent-auth', version: 1, pattern: /(^|\/)\.?(codex|cursor|composer|windsurf|continue|aider|claude|gemini)\/auth\.json$/i },
55
+ // An MCP manifest carries each server's `env`, which is where its API keys
56
+ // are written.
57
+ { id: 'dsh-dlp/path-agent-mcp-config', version: 1, pattern: /(^|\/)\.(cursor|windsurf|continue|codex|claude|gemini)\/mcp\.json$/i },
58
+ // Cursor keeps session tokens in a SQLite state database rather than a
59
+ // credential file.
60
+ { id: 'dsh-dlp/path-editor-state-db', version: 1, pattern: /(^|\/)state\.vscdb(-journal|-wal|-shm)?$/i },
61
+ { id: 'dsh-dlp/path-macos-keychain', version: 1, pattern: /(^|\/)Library\/Keychains(\/|$)/i },
62
+ // A Terraform variables file is where provider credentials are written by
63
+ // convention, and state holds every provider's secrets in plaintext.
64
+ { id: 'dsh-dlp/path-terraform-vars', version: 1, pattern: /\.tfvars(\.json)?$/i },
65
+ { id: 'dsh-dlp/path-terraform-state', version: 1, pattern: /(^|\/)terraform\.tfstate(\.backup)?$/i },
51
66
  { id: 'dsh-dlp/path-keystore', version: 2, pattern: /\.(pem|p12|pfx|jks|keystore|key|asc|gpg)$/i },
52
67
  { id: 'dsh-dlp/path-credential-name', version: 1, pattern: new RegExp(String.raw `(^|\/)(?!.*\.(?:${CODE_EXTENSIONS})$)[^/]*(credentials?|secrets?|tokens?)([._-][^/]*)?$`, 'i') },
53
68
  ];
69
+ /**
70
+ * Escape one literal path so it can anchor a regular expression.
71
+ * @param literal - the path to quote.
72
+ * @returns the same text with every metacharacter escaped.
73
+ */
74
+ export function escapePathPattern(literal) {
75
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
76
+ }
77
+ /**
78
+ * Credential-path rules anchored at the user's home directory, resolved at
79
+ * mount because the directory is not known until then.
80
+ *
81
+ * A coding agent's *home* configuration decides how every future session in
82
+ * every repository behaves: the Miasma worm's `SessionStart` hooks went into
83
+ * exactly these files. Writing one is never ordinary repository work, so it is
84
+ * on the floor. Reading one is — a user asking the agent why its own
85
+ * configuration behaves a certain way is a normal request — so the rule is
86
+ * `writes-only` rather than `every-call`, unlike the `auth.json` and `mcp.json`
87
+ * stores in {@link CREDENTIAL_PATH_RULES}, which hold nothing but credentials.
88
+ *
89
+ * The *repository-local* copies of these same file names are a different
90
+ * question with a different answer: they are edited legitimately and often, so
91
+ * they sit in the neutralizable `ask` tier rather than on the floor.
92
+ * @param home - the user's home directory.
93
+ * @returns rules appended after the built-in table.
94
+ */
95
+ export function homeCredentialPathRules(home) {
96
+ // `~` survives normalization as a root-anchored marker, so a home-relative
97
+ // spelling reaches the same rule as the absolute one.
98
+ const anchor = `(?:${escapePathPattern(home)}|/~)`;
99
+ return [{
100
+ id: 'dsh-dlp/path-agent-home-settings',
101
+ version: 1,
102
+ enforcement: 'writes-only',
103
+ pattern: new RegExp(`^${anchor}/\\.(claude|gemini|codex|cursor|windsurf|continue)/settings[^/]*\\.json$`, 'i'),
104
+ }];
105
+ }
54
106
  /**
55
107
  * Normalize one candidate path for matching: Windows separators become
56
108
  * forward slashes, surrounding quotes come off, `~` expands to a
package/lib/policy.js CHANGED
@@ -15,12 +15,13 @@
15
15
  * @module dsh-dlp/policy
16
16
  */
17
17
  import { readFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
18
19
  import { resolve } from 'node:path';
19
20
  import { JSON_SCHEMA, load } from 'js-yaml';
20
21
  import z from '@deepseek-ai/schemastery';
21
- import { SYNC_RULES, severityRank } from "./detectors.js";
22
+ import { SYNC_RULES, severityRank, stripControlSequences } from "./detectors.js";
22
23
  import { resolveDshHome } from "./home.js";
23
- import { CREDENTIAL_PATH_RULES } from "./paths.js";
24
+ import { CREDENTIAL_PATH_RULES, escapePathPattern, homeCredentialPathRules } from "./paths.js";
24
25
  export const Config = z.object({
25
26
  auditLog: z.string().required(),
26
27
  redactionKeyFile: z.string().required(),
@@ -29,10 +30,19 @@ export const Config = z.object({
29
30
  breadthTier: z.boolean().default(true),
30
31
  resultRedaction: z.boolean().default(true),
31
32
  telemetryRedaction: z.boolean().default(true),
33
+ remoteImageNeutralization: z.boolean().default(true),
32
34
  redactTelemetryWorkspacePaths: z.boolean().default(true),
35
+ configWriteAsk: z.boolean().default(true),
33
36
  });
34
37
  /** Config toggles a repo-local policy may switch on, and never off. */
35
- const ENABLEABLE = ['breadthTier', 'resultRedaction', 'telemetryRedaction', 'redactTelemetryWorkspacePaths'];
38
+ const ENABLEABLE = [
39
+ 'breadthTier',
40
+ 'resultRedaction',
41
+ 'telemetryRedaction',
42
+ 'remoteImageNeutralization',
43
+ 'redactTelemetryWorkspacePaths',
44
+ 'configWriteAsk',
45
+ ];
36
46
  /** Keys a repo-local policy file may carry; anything else fails the load. */
37
47
  const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
38
48
  /** Payload version this package writes and accepts for repo-local policy files. */
@@ -112,6 +122,12 @@ function parseCredentialPathEntry(node, index) {
112
122
  if (typeof id !== 'string' || id.length === 0) {
113
123
  throw new PolicyError(`addCredentialPaths[${index}].id must be a non-empty string`);
114
124
  }
125
+ // A rule id is quoted verbatim in a model-facing denial and in every audit
126
+ // record the rule produces, and this file is attacker-controlled.
127
+ if (stripControlSequences(id) !== id) {
128
+ throw new PolicyError(`addCredentialPaths[${index}].id carries a terminal control sequence; a rule id is quoted in a denial`
129
+ + ' the user reads and in the audit record, so it may not rewrite what is on screen');
130
+ }
115
131
  if (typeof pattern !== 'string' || pattern.length === 0) {
116
132
  throw new PolicyError(`addCredentialPaths[${index}].pattern must be a non-empty string`);
117
133
  }
@@ -211,10 +227,6 @@ export function loadRepoPolicy(path) {
211
227
  return { kind: 'invalid', problem: String(error) };
212
228
  }
213
229
  }
214
- /** Escape one literal path so it can anchor a regular expression. */
215
- function escapePattern(literal) {
216
- return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
217
- }
218
230
  /**
219
231
  * Deny rules protecting this plugin's own state and the harness home.
220
232
  *
@@ -237,10 +249,10 @@ function escapePattern(literal) {
237
249
  * @returns rules appended after the built-in table.
238
250
  */
239
251
  function selfProtectionRules(config, dshHome) {
240
- const home = escapePattern(resolve(dshHome));
252
+ const home = escapePathPattern(resolve(dshHome));
241
253
  return [
242
- { id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.redactionKeyFile))}$`, 'i') },
243
- { id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.auditLog))}$`, 'i') },
254
+ { id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePathPattern(resolve(config.redactionKeyFile))}$`, 'i') },
255
+ { id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${escapePathPattern(resolve(config.auditLog))}$`, 'i') },
244
256
  { id: 'dsh-dlp/path-dsh-sessions', version: 1, pattern: new RegExp(`^${home}/sessions(/|$)`, 'i') },
245
257
  { id: 'dsh-dlp/path-dsh-home', version: 2, enforcement: 'writes-only', pattern: new RegExp(`^${home}(/|$)`, 'i') },
246
258
  ];
@@ -256,6 +268,7 @@ export function resolvePolicy(config, repo) {
256
268
  return {
257
269
  credentialPathRules: [
258
270
  ...CREDENTIAL_PATH_RULES,
271
+ ...homeCredentialPathRules(resolve(homedir())),
259
272
  ...selfProtectionRules(config, resolveDshHome()),
260
273
  ...repo?.addCredentialPaths ?? [],
261
274
  ],
@@ -268,6 +281,8 @@ export function resolvePolicy(config, repo) {
268
281
  breadthTier: enabled('breadthTier'),
269
282
  resultRedaction: enabled('resultRedaction'),
270
283
  telemetryRedaction: enabled('telemetryRedaction'),
284
+ remoteImageNeutralization: enabled('remoteImageNeutralization'),
271
285
  redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
286
+ configWriteAsk: enabled('configWriteAsk'),
272
287
  };
273
288
  }
package/lib/sink.js CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { appendFileSync } from 'node:fs';
18
18
  import { randomUUID } from 'node:crypto';
19
+ import { stripControlSequences } from "./detectors.js";
19
20
  /**
20
21
  * Mint a decision id.
21
22
  * @returns an id unique to one guard verdict or redaction pass.
@@ -25,6 +26,29 @@ export function newDecisionId() {
25
26
  }
26
27
  /** Payload version carried inside every record this plugin writes. */
27
28
  export const RECORD_VERSION = 1;
29
+ /**
30
+ * One record with every string cleaned of terminal control sequences.
31
+ *
32
+ * A record carries strings this plugin did not author — a tool's registered
33
+ * name, a call id, a rule id from the repo-local policy tier — and a reader
34
+ * gets them back unescaped: `JSON.stringify` writes `\u001b` to the file, but
35
+ * `dsh-dlp report`, `jq -r` and any log viewer parse that back into a live
36
+ * escape. A tool named with a CSI sequence could then overwrite the line
37
+ * describing it, which is the forged-audit-record half of CVE-2026-35651.
38
+ * Cleaning here means every consumer of the file gets the cleaned form.
39
+ * @param value - any part of a record.
40
+ * @returns the same structure with control sequences replaced.
41
+ */
42
+ function cleaned(value) {
43
+ if (typeof value === 'string')
44
+ return stripControlSequences(value);
45
+ if (Array.isArray(value))
46
+ return value.map(cleaned);
47
+ if (typeof value === 'object' && value !== null) {
48
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [stripControlSequences(key), cleaned(item)]));
49
+ }
50
+ return value;
51
+ }
28
52
  /** Append-only JSONL sink for this plugin's decisions. */
29
53
  export class AuditSink {
30
54
  #path;
@@ -49,7 +73,7 @@ export class AuditSink {
49
73
  */
50
74
  write(record) {
51
75
  try {
52
- appendFileSync(this.#path, `${JSON.stringify(record)}\n`);
76
+ appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`);
53
77
  }
54
78
  catch (error) {
55
79
  this.#onFailure(error);
package/lib/telemetry.js CHANGED
@@ -27,6 +27,35 @@
27
27
  */
28
28
  import { scanSync } from "./detectors.js";
29
29
  import { placeholderFor, redactJson, redactText } from "./redaction.js";
30
+ /**
31
+ * What to tell the operator when the redaction seam will never dispatch.
32
+ *
33
+ * A `session-telemetry/record` listener mounts successfully and never runs
34
+ * unless a backend built a coordinator, and the shipped default builds none:
35
+ * the mode is `DISABLED`, so nothing is exported and nothing is dispatched.
36
+ * That is the safe posture, not a leak — but an operator who mounts a redactor
37
+ * under it sees every signal of success and has verified nothing. The
38
+ * backend's own `sharing` disclosure is the resolved answer, so this never
39
+ * guesses at `DSH_TELEMETRY_MODE`, which is only the base patch's default
40
+ * expression for a `mode` a deployment can also set directly.
41
+ * @param sharing - the mounted backend's disclosure, or `undefined` when no backend is mounted.
42
+ * @returns the line to report, or `undefined` when the seam does dispatch.
43
+ */
44
+ export function telemetrySeamNotice(sharing) {
45
+ const consequence = 'nothing dispatches the session-telemetry/record waterfall and this plugin\'s telemetry'
46
+ + ' redaction never runs. Nothing is exported in this state, so this is not a leak — it means the redaction'
47
+ + ' rules are unverified, and they begin running the moment telemetry is turned on. Informational only: the'
48
+ + ' plugin\'s other seams are unaffected.';
49
+ switch (sharing) {
50
+ case 'disabled':
51
+ return 'dsh-dlp: telemetryRedaction is enabled, but the mounted session-telemetry backend reports sharing'
52
+ + ` "disabled", so ${consequence}`;
53
+ case undefined:
54
+ return `dsh-dlp: telemetryRedaction is enabled, but no session-telemetry backend is mounted, so ${consequence}`;
55
+ default:
56
+ return undefined;
57
+ }
58
+ }
30
59
  /** Attribute keys whose values are filesystem paths rather than payload text. */
31
60
  const PATH_ATTRIBUTES = ['session.cwd'];
32
61
  /** Rule identity recorded when a workspace path is replaced. */
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The `ask` tier: files whose contents decide how the agent, the editor or CI
3
+ * behaves next time, and the one config *key* whose value redirects a
4
+ * credential.
5
+ *
6
+ * This tier is deliberately **not** on the guard floor, and that is the whole
7
+ * design. `ctx.tools.guard()` has no `ask` arm and cannot be overridden, so a
8
+ * rule that lands there must be one a developer never legitimately trips. A
9
+ * developer asks the agent to edit `CLAUDE.md`, add a `.github/workflows`
10
+ * job or extend `.vscode/settings.json` constantly. Putting those on an
11
+ * unoverridable floor produces a plugin that gets uninstalled, which removes
12
+ * the floor as well.
13
+ *
14
+ * The cost is stated rather than hidden: `tools/pre-execute` is neutralizable.
15
+ * A listener registered ahead of this one can return without calling `next()`
16
+ * and this tier never runs. Only the guard floor is order-independent.
17
+ * @module dsh-dlp/config-writes
18
+ */
19
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
20
+ /** What a behaviour-config rule is matched against. */
21
+ export type ConfigMatch =
22
+ /** A path-typed argument: the file the call would create or change. */
23
+ 'path'
24
+ /** A content-typed argument: the bytes the call would write. */
25
+ | 'content';
26
+ /** One file, or one written value, that changes what happens next time. */
27
+ export interface ConfigWriteRule {
28
+ readonly id: string;
29
+ readonly version: number;
30
+ readonly match: ConfigMatch;
31
+ readonly pattern: RegExp;
32
+ /** What the file or value does, quoted in the prompt the user answers. */
33
+ readonly effect: string;
34
+ }
35
+ /**
36
+ * Paths whose contents change future behaviour, and the config key whose value
37
+ * redirects credentials.
38
+ *
39
+ * Every path here was used in the wild. The Miasma worm wrote `SessionStart`
40
+ * hooks into `.claude/settings.json` and `.gemini/settings.json`, an
41
+ * always-apply `.cursor/rules/setup.mdc`, a `folderOpen` task into
42
+ * `.vscode/tasks.json`, and a hijacked `npm test` into `Azure/durabletask`;
43
+ * GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples
44
+ * over it, 39 of them inside 38 seconds. See also CVE-2025-53773,
45
+ * CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
46
+ * CVE-2025-59041.
47
+ *
48
+ * The rules match by name, never by what is on disk, so a file the call is
49
+ * about to *create* is matched exactly like one it would change:
50
+ * CVE-2026-25725 worked precisely because the path did not exist yet and was
51
+ * therefore writable without any prompt.
52
+ */
53
+ export declare const CONFIG_WRITE_RULES: readonly ConfigWriteRule[];
54
+ /**
55
+ * Argument keys whose values are the bytes a call would write.
56
+ *
57
+ * Deliberately separate from the floor's path-typed keys: the floor must never
58
+ * run its path table over file content, and this tier must run its one content
59
+ * rule over nothing else.
60
+ */
61
+ export declare const CONTENT_ARGUMENT_KEYS: ReadonlySet<string>;
62
+ /**
63
+ * The strings one call would write.
64
+ * @param args - the pending call's parsed arguments.
65
+ * @returns every string under a content-typed key, at any depth.
66
+ */
67
+ export declare function contentArguments(args: unknown): string[];
68
+ /** A call this tier wants a human to confirm. */
69
+ export interface ConfigWriteFinding {
70
+ readonly rule: ConfigWriteRule;
71
+ /** Model- and user-facing text; names the rule and what the file does, never the path. */
72
+ readonly reason: string;
73
+ }
74
+ /**
75
+ * Decide whether one call should be confirmed by a human.
76
+ *
77
+ * Only calls that can change something are examined: a tool
78
+ * {@link isReadOnlyTool} classifies as query-only cannot write a hook. Shell
79
+ * command lines are deliberately **not** tokenised here, unlike in the floor:
80
+ * a command line cannot be told apart from a read of the same path, and
81
+ * prompting on `cat .github/workflows/ci.yml` is exactly the false positive
82
+ * that gets a tier switched off. A shell redirection into one of these files
83
+ * is therefore not covered, which README.md says beside the feature.
84
+ * @param exec - the pending call.
85
+ * @param rules - the rule table; defaults to {@link CONFIG_WRITE_RULES}.
86
+ * @returns the finding, or `undefined` to leave the call alone.
87
+ */
88
+ export declare function evaluateConfigWrite(exec: Pick<ToolExecution, 'name' | 'arguments'>, rules?: readonly ConfigWriteRule[]): ConfigWriteFinding | undefined;
89
+ //# sourceMappingURL=config-writes.d.ts.map