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/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 for its logger.
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.logger.error(`dsh-dlp: ignoring the repo-local policy at ${policyFile}: ${load.problem}`);
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.logger.error(`dsh-dlp: audit sink write failed: ${String(error)}`);
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
- const redacted = await redactDecision(await next(), result, policy, hasher);
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
- if (redacted.spans.length > 0 || redacted.truncatedScan) {
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
@@ -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 { homedir } from 'node:os';
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 = ['breadthTier', 'resultRedaction', 'telemetryRedaction', 'redactTelemetryWorkspacePaths'];
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-home', version: 1, pattern: new RegExp(`^${escapePattern(resolve(dshHome))}(/|$)`, 'i') },
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(detection => ({ ...detection, ...expand(text, detection.start, detection.end) }))
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 can fail: the placeholder is re-validated against the
127
- * tool's `output.schema`, and a schema that constrains the string rejects it,
128
- * which the registry reports as a `ToolOutputError`. A failed call is the
129
- * intended outcome there — see README.md.
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
+ }