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/README.md +282 -12
- package/SECURITY.md +14 -6
- package/cordis.patch.yml +2 -0
- package/lib/cli.js +8 -4
- package/lib/config-writes.js +215 -0
- package/lib/detectors.js +136 -22
- package/lib/images.js +183 -0
- package/lib/index.js +140 -2
- package/lib/mutation.js +102 -0
- package/lib/paths.js +52 -0
- package/lib/policy.js +25 -10
- package/lib/sink.js +25 -1
- package/lib/telemetry.js +29 -0
- package/lib/types/config-writes.d.ts +89 -0
- package/lib/types/detectors.d.ts +41 -5
- package/lib/types/images.d.ts +81 -0
- package/lib/types/index.d.ts +14 -1
- package/lib/types/mutation.d.ts +83 -0
- package/lib/types/paths.d.ts +25 -0
- package/lib/types/policy.d.ts +11 -1
- package/lib/types/sink.d.ts +18 -1
- package/lib/types/telemetry.d.ts +16 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `dsh-dlp` — data-loss prevention for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 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
|
package/lib/mutation.js
ADDED
|
@@ -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 = [
|
|
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 =
|
|
252
|
+
const home = escapePathPattern(resolve(dshHome));
|
|
241
253
|
return [
|
|
242
|
-
{ id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${
|
|
243
|
-
{ id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${
|
|
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
|