dsh-dlp 0.1.0 → 0.3.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 +297 -21
- package/SECURITY.md +1 -1
- package/cordis.patch.yml +1 -0
- package/lib/cli.js +307 -0
- package/lib/detectors.js +87 -0
- package/lib/guard.js +9 -6
- package/lib/home.js +37 -0
- package/lib/images.js +183 -0
- package/lib/index.js +112 -7
- package/lib/mutation.js +102 -0
- package/lib/paths.js +38 -0
- package/lib/policy.js +24 -17
- package/lib/redaction.js +9 -1
- package/lib/results.js +25 -9
- package/lib/schema.js +99 -0
- package/lib/telemetry.js +29 -0
- package/lib/types/cli.d.ts +107 -0
- package/lib/types/detectors.d.ts +66 -0
- package/lib/types/guard.d.ts +6 -4
- package/lib/types/home.d.ts +31 -0
- package/lib/types/images.d.ts +81 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/mutation.d.ts +83 -0
- package/lib/types/paths.d.ts +38 -0
- package/lib/types/policy.d.ts +4 -10
- package/lib/types/results.d.ts +15 -6
- package/lib/types/schema.d.ts +41 -0
- package/lib/types/sink.d.ts +18 -1
- package/lib/types/telemetry.d.ts +16 -1
- package/package.json +4 -1
package/lib/index.js
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
15
15
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
16
16
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
17
|
+
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
|
18
|
+
* assistant output, before the text becomes a session event.
|
|
19
|
+
*
|
|
20
|
+
* Three of those registrations mitigate defects in the harness rather than in
|
|
21
|
+
* a deployment's own configuration: the missing Content-Security-Policy behind
|
|
22
|
+
* (5), the mutable execution object behind the guard's mutation check, and the
|
|
23
|
+
* silently inert telemetry seam behind the notice reported at mount. Each one
|
|
24
|
+
* is partial, none closes its channel, and README.md says so beside the
|
|
25
|
+
* feature.
|
|
17
26
|
*
|
|
18
27
|
* This plugin is not a containment boundary. It runs in-process at the agent's
|
|
19
28
|
* own uid; anything the agent can execute can read the same files the guard
|
|
@@ -25,8 +34,10 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
25
34
|
import { loadRepoPolicy, resolvePolicy } from "./policy.js";
|
|
26
35
|
import { SpanHasher } from "./redaction.js";
|
|
27
36
|
import { safeEvaluateGuard } from "./guard.js";
|
|
37
|
+
import { neutralizeImageStream } from "./images.js";
|
|
38
|
+
import { ExecutionSnapshots, mutationReason } from "./mutation.js";
|
|
28
39
|
import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
|
|
29
|
-
import { redactRecord } from "./telemetry.js";
|
|
40
|
+
import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
|
|
30
41
|
import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
|
|
31
42
|
export { Config } from "./policy.js";
|
|
32
43
|
/** Display metadata; labels the plugin in Cordis diagnostics. */
|
|
@@ -69,6 +80,31 @@ export function loadOrCreateKey(path) {
|
|
|
69
80
|
writeFileSync(path, created, { mode: 0o600 });
|
|
70
81
|
return created;
|
|
71
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Report a plugin fault on both the deployment's logger and `process.stderr`.
|
|
85
|
+
*
|
|
86
|
+
* `ctx.logger`'s default exporter is an in-memory 1000-entry ring buffer and
|
|
87
|
+
* no shipped bundle mounts a console exporter, so a message that goes only to
|
|
88
|
+
* the logger is invisible on a stock install — which is what an invalid policy
|
|
89
|
+
* file and a failed audit write were. `process.stderr` is what the headless
|
|
90
|
+
* runner itself writes to.
|
|
91
|
+
* @param ctx - the plugin's context, used for its logger.
|
|
92
|
+
* @param message - the whole line to report; never carries a matched value.
|
|
93
|
+
*/
|
|
94
|
+
function report(ctx, message) {
|
|
95
|
+
ctx.logger.error(message);
|
|
96
|
+
process.stderr.write(`${message}\n`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Report something the operator should know that is not a fault, on the same
|
|
100
|
+
* two channels and for the same reason as {@link report}.
|
|
101
|
+
* @param ctx - the plugin's context, used for its logger.
|
|
102
|
+
* @param message - the whole line to report.
|
|
103
|
+
*/
|
|
104
|
+
function notice(ctx, message) {
|
|
105
|
+
ctx.logger.warn(message);
|
|
106
|
+
process.stderr.write(`${message}\n`);
|
|
107
|
+
}
|
|
72
108
|
/**
|
|
73
109
|
* Load the repo-local policy tier, if the deployment named one.
|
|
74
110
|
*
|
|
@@ -77,7 +113,7 @@ export function loadOrCreateKey(path) {
|
|
|
77
113
|
* well-formed: the recommended `policyFile` is workspace-relative, so failing
|
|
78
114
|
* the mount would refuse to start `dsh` in every repository without one, and
|
|
79
115
|
* would let a hostile repository disable the plugin by shipping a broken file.
|
|
80
|
-
* @param ctx - the plugin's context, used only
|
|
116
|
+
* @param ctx - the plugin's context, used only to report a bad file.
|
|
81
117
|
* @param policyFile - the configured path, or `undefined` when the deployment named none.
|
|
82
118
|
* @returns the validated policy, or `undefined` when there is none to apply.
|
|
83
119
|
*/
|
|
@@ -91,7 +127,7 @@ function loadConfiguredPolicy(ctx, policyFile) {
|
|
|
91
127
|
case 'loaded':
|
|
92
128
|
return load.policy;
|
|
93
129
|
case 'invalid':
|
|
94
|
-
ctx
|
|
130
|
+
report(ctx, `dsh-dlp: ignoring the repo-local policy at ${policyFile}: ${load.problem}`);
|
|
95
131
|
return undefined;
|
|
96
132
|
/* v8 ignore next 4 -- unreachable while `RepoPolicyLoad` stays closed; the arm exists so adding a variant fails the build. */
|
|
97
133
|
default: {
|
|
@@ -110,7 +146,7 @@ export function apply(ctx, config) {
|
|
|
110
146
|
const hasher = new SpanHasher(loadOrCreateKey(config.redactionKeyFile));
|
|
111
147
|
const correlator = new CallCorrelator();
|
|
112
148
|
const sink = new AuditSink(config.auditLog, (error) => {
|
|
113
|
-
ctx
|
|
149
|
+
report(ctx, `dsh-dlp: audit sink write failed: ${String(error)}`);
|
|
114
150
|
});
|
|
115
151
|
/** Identity every audit record carries; the session-log envelope carries none of it. */
|
|
116
152
|
const identity = (exec) => {
|
|
@@ -123,7 +159,30 @@ export function apply(ctx, config) {
|
|
|
123
159
|
...position === undefined ? {} : { turn: position.turn, step: position.step },
|
|
124
160
|
};
|
|
125
161
|
};
|
|
162
|
+
const snapshots = new ExecutionSnapshots(hasher);
|
|
163
|
+
/**
|
|
164
|
+
* Report the telemetry seam's state once.
|
|
165
|
+
*
|
|
166
|
+
* At mount only a backend that is already there answers the question: the
|
|
167
|
+
* backend can load after this plugin, and calling that absence "inert" would
|
|
168
|
+
* be a false alarm. `conclusive` marks the later call, made once the harness
|
|
169
|
+
* is running sessions, where an absent backend really means no dispatcher.
|
|
170
|
+
*/
|
|
171
|
+
let telemetrySeamReported = false;
|
|
172
|
+
const discloseTelemetrySeam = (conclusive) => {
|
|
173
|
+
if (telemetrySeamReported)
|
|
174
|
+
return;
|
|
175
|
+
const backend = ctx.get('sessionTelemetry');
|
|
176
|
+
if (backend === undefined && !conclusive)
|
|
177
|
+
return;
|
|
178
|
+
telemetrySeamReported = true;
|
|
179
|
+
const line = telemetrySeamNotice(backend?.sharing);
|
|
180
|
+
if (line !== undefined)
|
|
181
|
+
notice(ctx, line);
|
|
182
|
+
};
|
|
126
183
|
ctx.on('session/event', (_session, event) => {
|
|
184
|
+
if (policy.telemetryRedaction)
|
|
185
|
+
discloseTelemetrySeam(true);
|
|
127
186
|
if (event.type === 'tool/call') {
|
|
128
187
|
correlator.note(event.data.callId, { turn: event.data.turn, step: event.data.step });
|
|
129
188
|
return;
|
|
@@ -132,9 +191,32 @@ export function apply(ctx, config) {
|
|
|
132
191
|
correlator.forget(event.data.message.source.callId);
|
|
133
192
|
}
|
|
134
193
|
});
|
|
194
|
+
// Snapshot each call before the rest of the waterfall can rewrite it. The
|
|
195
|
+
// prepend is best-effort by construction: a listener registered later with
|
|
196
|
+
// the same option runs ahead of this one.
|
|
197
|
+
ctx.on('tools/pre-execute', (exec, next) => {
|
|
198
|
+
snapshots.record(exec);
|
|
199
|
+
return next();
|
|
200
|
+
}, { prepend: true });
|
|
135
201
|
// The floor. Registered on a plain context so it applies globally: to every
|
|
136
202
|
// agent, every `run_code` inner sub-call, and every subagent child.
|
|
137
203
|
ctx.effect(() => ctx.tools.guard((exec) => {
|
|
204
|
+
// Integrity first: a call whose name or arguments changed after `tool/call`
|
|
205
|
+
// was appended is denied whatever the policy tables say about it, because
|
|
206
|
+
// the log no longer describes what would run.
|
|
207
|
+
const mutation = snapshots.detect(exec);
|
|
208
|
+
if (mutation !== undefined) {
|
|
209
|
+
sink.write({
|
|
210
|
+
v: RECORD_VERSION,
|
|
211
|
+
time: new Date().toISOString(),
|
|
212
|
+
kind: 'execution-mutation',
|
|
213
|
+
decisionId: newDecisionId(),
|
|
214
|
+
...identity(exec),
|
|
215
|
+
mutatedFields: mutation.fields,
|
|
216
|
+
...mutation.fields.includes('name') ? { originalTool: mutation.originalTool } : {},
|
|
217
|
+
});
|
|
218
|
+
return mutationReason(exec, mutation);
|
|
219
|
+
}
|
|
138
220
|
const verdict = safeEvaluateGuard(exec, policy, hasher);
|
|
139
221
|
if (verdict === undefined)
|
|
140
222
|
return undefined;
|
|
@@ -169,11 +251,20 @@ export function apply(ctx, config) {
|
|
|
169
251
|
}
|
|
170
252
|
if (policy.resultRedaction) {
|
|
171
253
|
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
172
|
-
|
|
254
|
+
// The live definition, so a schema the deployment's own tool declares is
|
|
255
|
+
// read from the registry rather than assumed. `exec.agent` is the scope
|
|
256
|
+
// key: a scoped tool shadows a global one of the same name.
|
|
257
|
+
const outputSchema = ctx.tools.get(exec.name, exec.agent)?.output.schema;
|
|
258
|
+
const redacted = await redactDecision(await next(), result, policy, hasher, outputSchema);
|
|
259
|
+
const indicators = Object.keys(redacted.indicators).length > 0
|
|
260
|
+
? { unicode: redacted.indicators }
|
|
261
|
+
: {};
|
|
173
262
|
// A truncated scan is recorded even with nothing found: without a record
|
|
174
263
|
// an operator cannot tell "this result was clean" from "this result was
|
|
175
|
-
// only partly examined".
|
|
176
|
-
|
|
264
|
+
// only partly examined". An invisible-character run is recorded on the
|
|
265
|
+
// same terms, because the `report` classes are never replaced and the
|
|
266
|
+
// count is the only trace they leave.
|
|
267
|
+
if (redacted.spans.length > 0 || redacted.truncatedScan || Object.keys(indicators).length > 0) {
|
|
177
268
|
sink.write({
|
|
178
269
|
v: RECORD_VERSION,
|
|
179
270
|
time: new Date().toISOString(),
|
|
@@ -182,12 +273,26 @@ export function apply(ctx, config) {
|
|
|
182
273
|
...identity(exec),
|
|
183
274
|
spans: redacted.spans,
|
|
184
275
|
...redacted.truncatedScan ? { truncatedScan: true } : {},
|
|
276
|
+
...indicators,
|
|
185
277
|
});
|
|
186
278
|
}
|
|
187
279
|
return redacted.decision;
|
|
188
280
|
});
|
|
189
281
|
}
|
|
282
|
+
if (policy.remoteImageNeutralization) {
|
|
283
|
+
ctx.on('llm/stream', (options, next) => neutralizeImageStream(next(), (host) => {
|
|
284
|
+
sink.write({
|
|
285
|
+
v: RECORD_VERSION,
|
|
286
|
+
time: new Date().toISOString(),
|
|
287
|
+
kind: 'assistant-image-neutralized',
|
|
288
|
+
decisionId: newDecisionId(),
|
|
289
|
+
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
|
|
290
|
+
host,
|
|
291
|
+
});
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
190
294
|
if (policy.telemetryRedaction) {
|
|
295
|
+
discloseTelemetrySeam(false);
|
|
191
296
|
ctx.on('session-telemetry/record', (_record, next) => {
|
|
192
297
|
// Throwing here withholds this one record; the coordinator contains it
|
|
193
298
|
// 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
|
@@ -225,3 +225,41 @@ export function isEgressCapable(toolName, extraEgressTools = new Set()) {
|
|
|
225
225
|
return true;
|
|
226
226
|
return !LOCAL_TOOLS.has(toolName);
|
|
227
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Tools that can only look: they query the filesystem, the language server,
|
|
230
|
+
* the session store or a running job, and have no operation that changes
|
|
231
|
+
* anything.
|
|
232
|
+
*
|
|
233
|
+
* A `writes-only` rule is lifted for these names and for no others. Every
|
|
234
|
+
* shell, `run_code`, every editor, every `mcp__*` tool and any tool this build
|
|
235
|
+
* has never heard of stays on the deny side, so a new tool is denied until it
|
|
236
|
+
* is classified — the same default as {@link LOCAL_TOOLS}, in the same
|
|
237
|
+
* direction.
|
|
238
|
+
*/
|
|
239
|
+
export const READ_ONLY_TOOLS = new Set([
|
|
240
|
+
'read', 'read_image', 'glob', 'grep', 'lsp',
|
|
241
|
+
'session_search', 'session_trace',
|
|
242
|
+
'session_event_read', 'session_event_search', 'session_event_trace',
|
|
243
|
+
'list_agents', 'job_list', 'job_output',
|
|
244
|
+
'terminal_list', 'terminal_read',
|
|
245
|
+
'get_goal',
|
|
246
|
+
]);
|
|
247
|
+
/**
|
|
248
|
+
* Whether a tool is known to be incapable of changing anything.
|
|
249
|
+
* @param toolName - the executing tool's registered name.
|
|
250
|
+
* @returns `true` only for a name in {@link READ_ONLY_TOOLS}.
|
|
251
|
+
*/
|
|
252
|
+
export function isReadOnlyTool(toolName) {
|
|
253
|
+
return READ_ONLY_TOOLS.has(toolName);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The credential-path rules one tool is judged against.
|
|
257
|
+
* @param toolName - the executing tool's registered name.
|
|
258
|
+
* @param rules - the effective rule table.
|
|
259
|
+
* @returns every rule, minus the `writes-only` ones for a read-only tool.
|
|
260
|
+
*/
|
|
261
|
+
export function rulesForTool(toolName, rules) {
|
|
262
|
+
if (!isReadOnlyTool(toolName))
|
|
263
|
+
return rules;
|
|
264
|
+
return rules.filter(rule => rule.enforcement !== 'writes-only');
|
|
265
|
+
}
|
package/lib/policy.js
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
* @module dsh-dlp/policy
|
|
16
16
|
*/
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
-
import {
|
|
19
|
-
import { join, resolve } from 'node:path';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
20
19
|
import { JSON_SCHEMA, load } from 'js-yaml';
|
|
21
20
|
import z from '@deepseek-ai/schemastery';
|
|
22
21
|
import { SYNC_RULES, severityRank } from "./detectors.js";
|
|
22
|
+
import { resolveDshHome } from "./home.js";
|
|
23
23
|
import { CREDENTIAL_PATH_RULES } from "./paths.js";
|
|
24
24
|
export const Config = z.object({
|
|
25
25
|
auditLog: z.string().required(),
|
|
@@ -29,10 +29,17 @@ export const Config = z.object({
|
|
|
29
29
|
breadthTier: z.boolean().default(true),
|
|
30
30
|
resultRedaction: z.boolean().default(true),
|
|
31
31
|
telemetryRedaction: z.boolean().default(true),
|
|
32
|
+
remoteImageNeutralization: z.boolean().default(true),
|
|
32
33
|
redactTelemetryWorkspacePaths: z.boolean().default(true),
|
|
33
34
|
});
|
|
34
35
|
/** Config toggles a repo-local policy may switch on, and never off. */
|
|
35
|
-
const ENABLEABLE = [
|
|
36
|
+
const ENABLEABLE = [
|
|
37
|
+
'breadthTier',
|
|
38
|
+
'resultRedaction',
|
|
39
|
+
'telemetryRedaction',
|
|
40
|
+
'remoteImageNeutralization',
|
|
41
|
+
'redactTelemetryWorkspacePaths',
|
|
42
|
+
];
|
|
36
43
|
/** Keys a repo-local policy file may carry; anything else fails the load. */
|
|
37
44
|
const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
|
|
38
45
|
/** Payload version this package writes and accepts for repo-local policy files. */
|
|
@@ -216,36 +223,35 @@ function escapePattern(literal) {
|
|
|
216
223
|
return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
217
224
|
}
|
|
218
225
|
/**
|
|
219
|
-
* Deny rules protecting this plugin's own state.
|
|
226
|
+
* Deny rules protecting this plugin's own state and the harness home.
|
|
220
227
|
*
|
|
221
228
|
* Every one of these is known at mount: the key file whose bytes make a
|
|
222
229
|
* placeholder hash keyed rather than a bare digest, the append-only sink that
|
|
223
230
|
* is the only evidence a decision happened, and the harness home holding the
|
|
224
231
|
* provider credentials, the session logs and the profiles that decide which
|
|
225
232
|
* plugins load at all.
|
|
233
|
+
*
|
|
234
|
+
* The harness home is split. **Writing** anywhere under it is denied for every
|
|
235
|
+
* tool: a prompt-injected agent editing a profile's `cordis.yml` mounts an
|
|
236
|
+
* arbitrary plugin. **Reading** it is denied only where the contents are
|
|
237
|
+
* credentials — `.credentials.yaml`, `.env` and `*.key` are already in the
|
|
238
|
+
* built-in table, and the session logs get a rule here. Everything else under
|
|
239
|
+
* that directory is the installed plugin tree and the profile manifests, which
|
|
240
|
+
* a user debugging a plugin has every reason to read; a blanket read denial
|
|
241
|
+
* there was unoverridable and was the plugin's most likely uninstall reason.
|
|
226
242
|
* @param config - the deployment-controlled configuration.
|
|
227
243
|
* @param dshHome - the resolved harness home.
|
|
228
244
|
* @returns rules appended after the built-in table.
|
|
229
245
|
*/
|
|
230
246
|
function selfProtectionRules(config, dshHome) {
|
|
247
|
+
const home = escapePattern(resolve(dshHome));
|
|
231
248
|
return [
|
|
232
249
|
{ id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.redactionKeyFile))}$`, 'i') },
|
|
233
250
|
{ id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.auditLog))}$`, 'i') },
|
|
234
|
-
{ id: 'dsh-dlp/path-dsh-
|
|
251
|
+
{ id: 'dsh-dlp/path-dsh-sessions', version: 1, pattern: new RegExp(`^${home}/sessions(/|$)`, 'i') },
|
|
252
|
+
{ id: 'dsh-dlp/path-dsh-home', version: 2, enforcement: 'writes-only', pattern: new RegExp(`^${home}(/|$)`, 'i') },
|
|
235
253
|
];
|
|
236
254
|
}
|
|
237
|
-
/**
|
|
238
|
-
* Resolve the harness home the same way the harness does: `$DSH_HOME` when it
|
|
239
|
-
* is set to something other than whitespace, otherwise `~/.dsh`. Read here
|
|
240
|
-
* rather than through `@deepseek-ai/dsh-home-paths` to keep the plugin's
|
|
241
|
-
* runtime imports to the ones a profile is guaranteed to resolve.
|
|
242
|
-
* @param env - environment consulted for `DSH_HOME`; defaults to `process.env`.
|
|
243
|
-
* @returns the absolute harness home.
|
|
244
|
-
*/
|
|
245
|
-
export function resolveDshHome(env = process.env) {
|
|
246
|
-
const configured = env['DSH_HOME'];
|
|
247
|
-
return resolve(configured !== undefined && configured.trim().length > 0 ? configured : join(homedir(), '.dsh'));
|
|
248
|
-
}
|
|
249
255
|
/**
|
|
250
256
|
* Merge the deployment config with an optional repo-local policy.
|
|
251
257
|
* @param config - the deployment-controlled configuration.
|
|
@@ -269,6 +275,7 @@ export function resolvePolicy(config, repo) {
|
|
|
269
275
|
breadthTier: enabled('breadthTier'),
|
|
270
276
|
resultRedaction: enabled('resultRedaction'),
|
|
271
277
|
telemetryRedaction: enabled('telemetryRedaction'),
|
|
278
|
+
remoteImageNeutralization: enabled('remoteImageNeutralization'),
|
|
272
279
|
redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
|
|
273
280
|
};
|
|
274
281
|
}
|
package/lib/redaction.js
CHANGED
|
@@ -80,7 +80,15 @@ function expand(text, start, end) {
|
|
|
80
80
|
/** Detections merged into non-overlapping regions, each attributed to its strictest rule. */
|
|
81
81
|
function mergeSpans(text, detections) {
|
|
82
82
|
const expanded = detections
|
|
83
|
-
.map(
|
|
83
|
+
.map(({ ruleId, ruleVersion, severity, exact, start, end }) => ({
|
|
84
|
+
ruleId,
|
|
85
|
+
ruleVersion,
|
|
86
|
+
severity,
|
|
87
|
+
// An exact detection covers precisely what must go: widening an
|
|
88
|
+
// invisible character to its delimiters would delete the visible word
|
|
89
|
+
// around it.
|
|
90
|
+
...exact === true ? { start, end } : expand(text, start, end),
|
|
91
|
+
}))
|
|
84
92
|
.sort((left, right) => left.start - right.start || left.end - right.end);
|
|
85
93
|
const merged = [];
|
|
86
94
|
for (const candidate of expanded) {
|
package/lib/results.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* whole rule set applies here and not in the guard.
|
|
11
11
|
* @module dsh-dlp/results
|
|
12
12
|
*/
|
|
13
|
-
import { DENY_SEVERITY, scanAll, scanSync, severityRank, } from "./detectors.js";
|
|
13
|
+
import { DENY_SEVERITY, countUnicodeIndicators, scanAll, scanSync, severityRank, } from "./detectors.js";
|
|
14
14
|
import { isEgressCapable } from "./paths.js";
|
|
15
15
|
import { nestedStrings, redactContent, redactJson } from "./redaction.js";
|
|
16
|
+
import { redactionBreaksSchema } from "./schema.js";
|
|
16
17
|
/**
|
|
17
18
|
* Separator the strings of one result are rendered with before the
|
|
18
19
|
* cross-string scan. A newline is what the reader of a tool result sees
|
|
@@ -38,7 +39,7 @@ const RENDER_SEPARATOR = '\n';
|
|
|
38
39
|
* is scanned depend on the same accident.
|
|
39
40
|
* @param strings - every string that will be redacted, in render order.
|
|
40
41
|
* @param policy - the effective policy.
|
|
41
|
-
* @returns a memoized lookup and whether tier 2 saw less than the whole rendering.
|
|
42
|
+
* @returns a memoized lookup, the invisible-character counts, and whether tier 2 saw less than the whole rendering.
|
|
42
43
|
*/
|
|
43
44
|
async function prepareScan(strings, policy) {
|
|
44
45
|
const rendered = strings.join(RENDER_SEPARATOR);
|
|
@@ -69,7 +70,7 @@ async function prepareScan(strings, policy) {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
/* v8 ignore next -- every string handed to the walkers was collected for this memo. */
|
|
72
|
-
return { scan: text => memo.get(text) ?? [], truncated };
|
|
73
|
+
return { scan: text => memo.get(text) ?? [], truncated, indicators: countUnicodeIndicators(rendered) };
|
|
73
74
|
}
|
|
74
75
|
/** Text carried by a content array's text blocks. */
|
|
75
76
|
function contentStrings(blocks) {
|
|
@@ -123,10 +124,11 @@ function withheldFeedback(spans) {
|
|
|
123
124
|
* carries a secret, or a value that still scans dirty after redaction.
|
|
124
125
|
* Blocking replaces the whole result, which is the only way to drop `meta`.
|
|
125
126
|
*
|
|
126
|
-
* Replacing the value
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
127
|
+
* Replacing the value is re-validated by the registry against the tool's
|
|
128
|
+
* `output.schema`, and a schema that pins the redacted string rejects it. That
|
|
129
|
+
* surfaces as a `ToolOutputError` naming a validation failure, which tells the
|
|
130
|
+
* model nothing it can act on, so the schema is checked here first and the
|
|
131
|
+
* result is withheld with this plugin's own explanation instead.
|
|
130
132
|
*
|
|
131
133
|
* A downstream `accept{content}` over a dirty value is overruled by the value
|
|
132
134
|
* arm, which discards that listener's presentation choice. Keeping it would
|
|
@@ -136,9 +138,10 @@ function withheldFeedback(spans) {
|
|
|
136
138
|
* @param result - the dispatch outcome the waterfall was called with.
|
|
137
139
|
* @param policy - the effective policy.
|
|
138
140
|
* @param hasher - mints each span's keyed hash.
|
|
141
|
+
* @param outputSchema - the executing tool's declared output schema, when one could be resolved.
|
|
139
142
|
* @returns the decision to return, the spans replaced, and scan completeness.
|
|
140
143
|
*/
|
|
141
|
-
export async function redactDecision(decision, result, policy, hasher) {
|
|
144
|
+
export async function redactDecision(decision, result, policy, hasher, outputSchema) {
|
|
142
145
|
if (decision.kind === 'block') {
|
|
143
146
|
const prepared = await prepareScan(contentStrings(decision.feedback), policy);
|
|
144
147
|
const redacted = redactContent(decision.feedback, prepared.scan, hasher);
|
|
@@ -146,6 +149,7 @@ export async function redactDecision(decision, result, policy, hasher) {
|
|
|
146
149
|
decision: redacted.changed ? { ...decision, feedback: redacted.content } : decision,
|
|
147
150
|
spans: redacted.spans,
|
|
148
151
|
truncatedScan: prepared.truncated,
|
|
152
|
+
indicators: prepared.indicators,
|
|
149
153
|
};
|
|
150
154
|
}
|
|
151
155
|
const replacedValue = Object.hasOwn(decision, 'value') ? decision.value : undefined;
|
|
@@ -164,6 +168,14 @@ export async function redactDecision(decision, result, policy, hasher) {
|
|
|
164
168
|
const dirty = (strings) => strings.some(text => prepared.scan(text).length > 0);
|
|
165
169
|
if (value !== undefined && dirty(nestedStrings(value))) {
|
|
166
170
|
const redacted = redactJson(value, prepared.scan, hasher);
|
|
171
|
+
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
|
+
};
|
|
178
|
+
}
|
|
167
179
|
const remaining = nestedStrings(redacted.value);
|
|
168
180
|
const residual = await prepareScan(remaining, policy);
|
|
169
181
|
if (remaining.some(text => residual.scan(text).length > 0)) {
|
|
@@ -171,12 +183,14 @@ export async function redactDecision(decision, result, policy, hasher) {
|
|
|
171
183
|
decision: { kind: 'block', feedback: withheldFeedback(redacted.spans) },
|
|
172
184
|
spans: redacted.spans,
|
|
173
185
|
truncatedScan: prepared.truncated || residual.truncated,
|
|
186
|
+
indicators: prepared.indicators,
|
|
174
187
|
};
|
|
175
188
|
}
|
|
176
189
|
return {
|
|
177
190
|
decision: { kind: 'accept', value: redacted.value, ...contexts },
|
|
178
191
|
spans: redacted.spans,
|
|
179
192
|
truncatedScan: prepared.truncated,
|
|
193
|
+
indicators: prepared.indicators,
|
|
180
194
|
};
|
|
181
195
|
}
|
|
182
196
|
// The value is clean, so the durable result is clean unless `meta` — which
|
|
@@ -187,17 +201,19 @@ export async function redactDecision(decision, result, policy, hasher) {
|
|
|
187
201
|
decision: { kind: 'block', feedback: withheldFeedback(spans) },
|
|
188
202
|
spans,
|
|
189
203
|
truncatedScan: prepared.truncated,
|
|
204
|
+
indicators: prepared.indicators,
|
|
190
205
|
};
|
|
191
206
|
}
|
|
192
207
|
const blocks = replacedContent ?? result.content;
|
|
193
208
|
const redacted = redactContent(blocks, prepared.scan, hasher);
|
|
194
209
|
if (!redacted.changed) {
|
|
195
|
-
return { decision, spans: [], truncatedScan: prepared.truncated };
|
|
210
|
+
return { decision, spans: [], truncatedScan: prepared.truncated, indicators: prepared.indicators };
|
|
196
211
|
}
|
|
197
212
|
return {
|
|
198
213
|
decision: { kind: 'accept', content: redacted.content, ...contexts },
|
|
199
214
|
spans: redacted.spans,
|
|
200
215
|
truncatedScan: prepared.truncated,
|
|
216
|
+
indicators: prepared.indicators,
|
|
201
217
|
};
|
|
202
218
|
}
|
|
203
219
|
/**
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a redacted value still satisfies the tool's declared `output.schema`.
|
|
3
|
+
*
|
|
4
|
+
* Replacing a canonical value makes the registry re-validate it, so a schema
|
|
5
|
+
* that pins the redacted string turns a redaction into a `ToolOutputError` the
|
|
6
|
+
* model cannot act on. Asking the question first lets the listener withhold
|
|
7
|
+
* the result with its own explanation instead.
|
|
8
|
+
*
|
|
9
|
+
* This is a re-implementation of the harness's own check rather than a call
|
|
10
|
+
* into it: every harness type this package uses is imported with `import
|
|
11
|
+
* type`, so nothing from `@deepseek-ai/dsh-*` is emitted as a runtime import
|
|
12
|
+
* and the plugin resolves from a profile directory that has none of them
|
|
13
|
+
* installed. The enforced subset is small — `type`, `oneOf`, `properties`,
|
|
14
|
+
* `required`, `additionalProperties`, `items`, `enum`, `const` — and the
|
|
15
|
+
* caller guards against any disagreement by checking the *original* value
|
|
16
|
+
* first: a value this module rejects before redaction means the answer cannot
|
|
17
|
+
* be trusted, and the redaction proceeds as it did before.
|
|
18
|
+
* @module dsh-dlp/schema
|
|
19
|
+
*/
|
|
20
|
+
/** Whether a value is a plain JSON object rather than an array or a null. */
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
/** Whether a scalar passes the node's `enum` and `const` constraints. */
|
|
25
|
+
function allowsScalar(node, value) {
|
|
26
|
+
if (node.enum !== undefined && !node.enum.includes(value))
|
|
27
|
+
return false;
|
|
28
|
+
return node.const === undefined || value === node.const;
|
|
29
|
+
}
|
|
30
|
+
/** Whether every declared property of an object node is satisfied. */
|
|
31
|
+
function satisfiesObject(node, value) {
|
|
32
|
+
const properties = node.properties ?? {};
|
|
33
|
+
for (const key of node.required ?? []) {
|
|
34
|
+
if (!Object.hasOwn(value, key) || value[key] === undefined)
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (node.additionalProperties === false) {
|
|
38
|
+
for (const key of Object.keys(value)) {
|
|
39
|
+
if (!Object.hasOwn(properties, key))
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Object.entries(properties).every(([key, child]) => !Object.hasOwn(value, key) || value[key] === undefined || satisfiesJsonSchema(child, value[key]));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether one value satisfies one schema node.
|
|
47
|
+
* @param node - a node of the tool's declared output schema.
|
|
48
|
+
* @param value - the candidate value.
|
|
49
|
+
* @returns `true` when the registry's own validation would accept it.
|
|
50
|
+
*/
|
|
51
|
+
export function satisfiesJsonSchema(node, value) {
|
|
52
|
+
if (node.oneOf !== undefined) {
|
|
53
|
+
return node.oneOf.filter(branch => satisfiesJsonSchema(branch, value)).length === 1;
|
|
54
|
+
}
|
|
55
|
+
switch (node.type) {
|
|
56
|
+
case undefined:
|
|
57
|
+
return true;
|
|
58
|
+
case 'object':
|
|
59
|
+
return isRecord(value) && satisfiesObject(node, value);
|
|
60
|
+
case 'array': {
|
|
61
|
+
const items = node.items;
|
|
62
|
+
return Array.isArray(value) && (items === undefined || value.every(item => satisfiesJsonSchema(items, item)));
|
|
63
|
+
}
|
|
64
|
+
case 'string':
|
|
65
|
+
return typeof value === 'string' && allowsScalar(node, value);
|
|
66
|
+
case 'number':
|
|
67
|
+
return typeof value === 'number' && Number.isFinite(value) && allowsScalar(node, value);
|
|
68
|
+
case 'integer':
|
|
69
|
+
return typeof value === 'number' && Number.isInteger(value) && allowsScalar(node, value);
|
|
70
|
+
case 'boolean':
|
|
71
|
+
return typeof value === 'boolean' && allowsScalar(node, value);
|
|
72
|
+
case 'null':
|
|
73
|
+
return value === null && allowsScalar(node, value);
|
|
74
|
+
/* v8 ignore next 4 -- unreachable while `JsonSchemaType` stays closed; the arm exists so adding a type fails the build. */
|
|
75
|
+
default: {
|
|
76
|
+
const unhandled = node.type;
|
|
77
|
+
throw new TypeError(`dsh-dlp: unhandled JSON schema type ${JSON.stringify(unhandled)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether replacing a value with its redacted copy would fail the tool's
|
|
83
|
+
* output validation.
|
|
84
|
+
*
|
|
85
|
+
* A schema this module already rejects for the original value is one it does
|
|
86
|
+
* not model correctly, so the answer is `false` and the registry decides — the
|
|
87
|
+
* check can withhold a result, and it must never do so on its own confusion.
|
|
88
|
+
* @param schema - the tool's declared output schema, when one could be resolved.
|
|
89
|
+
* @param original - the value the tool produced.
|
|
90
|
+
* @param redacted - the value the redaction pass produced.
|
|
91
|
+
* @returns `true` only when the original validates and the redacted one does not.
|
|
92
|
+
*/
|
|
93
|
+
export function redactionBreaksSchema(schema, original, redacted) {
|
|
94
|
+
if (schema === undefined)
|
|
95
|
+
return false;
|
|
96
|
+
if (!satisfiesJsonSchema(schema, original))
|
|
97
|
+
return false;
|
|
98
|
+
return !satisfiesJsonSchema(schema, redacted);
|
|
99
|
+
}
|