dsh-dlp 0.1.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/policy.js ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Deployment configuration, the repo-local policy tier, and the tighten-only
3
+ * merge between them.
4
+ *
5
+ * Trust ranking, highest first:
6
+ *
7
+ * 1. invariants compiled into this package — the guard floor's tables; not configurable;
8
+ * 2. `cordis.yml` / bundle patch config — deployment-controlled; sets every field;
9
+ * 3. `policyFile` — a repo-local YAML file; **attacker-controlled**, may only tighten.
10
+ *
11
+ * Rank 3 is a file inside the workspace, so a hostile repository ships one and
12
+ * a prompt-injected agent can write one. It may add deny patterns, add egress
13
+ * tools, raise a severity, and switch a redaction pass on. Every other key,
14
+ * and every downgrade, is a load-time error rather than a silent ignore.
15
+ * @module dsh-dlp/policy
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+ import { JSON_SCHEMA, load } from 'js-yaml';
21
+ import z from '@deepseek-ai/schemastery';
22
+ import { SYNC_RULES, severityRank } from "./detectors.js";
23
+ import { CREDENTIAL_PATH_RULES } from "./paths.js";
24
+ export const Config = z.object({
25
+ auditLog: z.string().required(),
26
+ redactionKeyFile: z.string().required(),
27
+ policyFile: z.string(),
28
+ maxScanBytes: z.number().default(1024 * 1024),
29
+ breadthTier: z.boolean().default(true),
30
+ resultRedaction: z.boolean().default(true),
31
+ telemetryRedaction: z.boolean().default(true),
32
+ redactTelemetryWorkspacePaths: z.boolean().default(true),
33
+ });
34
+ /** Config toggles a repo-local policy may switch on, and never off. */
35
+ const ENABLEABLE = ['breadthTier', 'resultRedaction', 'telemetryRedaction', 'redactTelemetryWorkspacePaths'];
36
+ /** Keys a repo-local policy file may carry; anything else fails the load. */
37
+ const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
38
+ /** Payload version this package writes and accepts for repo-local policy files. */
39
+ export const POLICY_VERSION = 1;
40
+ /** Thrown when a policy file is malformed or attempts to loosen the policy. */
41
+ export class PolicyError extends Error {
42
+ /**
43
+ * @param message - what the file did and why it is rejected.
44
+ */
45
+ constructor(message) {
46
+ super(`dsh-dlp policy: ${message}`);
47
+ this.name = 'PolicyError';
48
+ }
49
+ }
50
+ /** Narrow one parsed YAML node to a plain object. */
51
+ function requireObject(node, what) {
52
+ if (typeof node !== 'object' || node === null || Array.isArray(node)) {
53
+ throw new PolicyError(`${what} must be a mapping`);
54
+ }
55
+ return node;
56
+ }
57
+ /** Narrow one parsed YAML node to an array of strings. */
58
+ function requireStringArray(node, what) {
59
+ if (!Array.isArray(node) || node.some(item => typeof item !== 'string')) {
60
+ throw new PolicyError(`${what} must be a list of strings`);
61
+ }
62
+ return node;
63
+ }
64
+ /** Narrow one parsed YAML node to a known severity. */
65
+ function requireSeverity(node, what) {
66
+ if (typeof node !== 'string' || severityRank(node) < 0) {
67
+ throw new PolicyError(`${what} must be one of low, medium, high, critical`);
68
+ }
69
+ return node;
70
+ }
71
+ /**
72
+ * Longest repo-authored pattern accepted. A credential path is a short,
73
+ * anchored expression; length past this buys nothing and grows the search
74
+ * space a catastrophic pattern can backtrack over.
75
+ */
76
+ const MAX_PATTERN_LENGTH = 200;
77
+ /**
78
+ * A quantifier applied to a group that already contains one — `(a+)+`,
79
+ * `(?:[a-z]*)*`. That shape is what turns a 27-character input into seconds of
80
+ * backtracking, and the guard runs synchronously on the agent's event loop.
81
+ *
82
+ * This is a heuristic, not a decision procedure: no regular-expression syntax
83
+ * check can prove a pattern runs in linear time, and other shapes
84
+ * (`(a|a)+`, `a*a*`) still backtrack. It rejects the shape that is both easy
85
+ * to write and expensive to run; the length cap bounds the rest.
86
+ */
87
+ const NESTED_QUANTIFIER = /\((?:\?[:=!<]*)?[^()]*[*+?}][^()]*\)\s*[*+{]/;
88
+ /**
89
+ * Reject a repo-authored pattern that would let a hostile repository stall the
90
+ * agent through the synchronous guard.
91
+ * @param pattern - the pattern text from the policy file.
92
+ * @param where - the field being validated, for the error message.
93
+ * @throws PolicyError when the pattern is too long or nests quantifiers.
94
+ */
95
+ function assertSafePattern(pattern, where) {
96
+ if (pattern.length > MAX_PATTERN_LENGTH) {
97
+ throw new PolicyError(`${where} is ${pattern.length} characters; at most ${MAX_PATTERN_LENGTH} are allowed`);
98
+ }
99
+ if (NESTED_QUANTIFIER.test(pattern)) {
100
+ throw new PolicyError(`${where} nests a quantifier inside a quantified group, which can take exponential time to match;`
101
+ + ' the guard runs synchronously, so such a pattern is rejected');
102
+ }
103
+ }
104
+ /** Compile one repo-local credential-path entry, rejecting a pattern that cannot be built. */
105
+ function parseCredentialPathEntry(node, index) {
106
+ const entry = requireObject(node, `addCredentialPaths[${index}]`);
107
+ const { id, pattern } = entry;
108
+ const extra = Object.keys(entry).filter(key => key !== 'id' && key !== 'pattern');
109
+ if (extra.length > 0) {
110
+ throw new PolicyError(`addCredentialPaths[${index}] has unknown keys: ${extra.join(', ')}`);
111
+ }
112
+ if (typeof id !== 'string' || id.length === 0) {
113
+ throw new PolicyError(`addCredentialPaths[${index}].id must be a non-empty string`);
114
+ }
115
+ if (typeof pattern !== 'string' || pattern.length === 0) {
116
+ throw new PolicyError(`addCredentialPaths[${index}].pattern must be a non-empty string`);
117
+ }
118
+ assertSafePattern(pattern, `addCredentialPaths[${index}].pattern`);
119
+ try {
120
+ return { id, version: POLICY_VERSION, pattern: new RegExp(pattern, 'i') };
121
+ }
122
+ catch (error) {
123
+ throw new PolicyError(`addCredentialPaths[${index}].pattern is not a valid regular expression: ${String(error)}`);
124
+ }
125
+ }
126
+ /**
127
+ * Parse and validate one repo-local policy document.
128
+ *
129
+ * Loaded under `js-yaml`'s `JSON_SCHEMA`, so a `!!js/function` tag is a parse
130
+ * error rather than code execution. This path deliberately never touches the
131
+ * Cordis loader, whose `!!js` support is the whole reason it must not see
132
+ * workspace-authored files.
133
+ * @param text - the file's contents.
134
+ * @returns the validated policy.
135
+ * @throws PolicyError on an unknown key, a bad value, or any attempt to loosen.
136
+ */
137
+ export function parseRepoPolicy(text) {
138
+ let parsed;
139
+ try {
140
+ parsed = load(text, { schema: JSON_SCHEMA });
141
+ }
142
+ catch (error) {
143
+ throw new PolicyError(`file is not safe-schema YAML: ${String(error)}`);
144
+ }
145
+ const document = requireObject(parsed, 'file');
146
+ const unknown = Object.keys(document).filter(key => !POLICY_KEYS.includes(key));
147
+ if (unknown.length > 0) {
148
+ throw new PolicyError(`unknown keys: ${unknown.join(', ')}. A repo-local policy may only tighten:`
149
+ + ` ${POLICY_KEYS.join(', ')}`);
150
+ }
151
+ if (document['v'] !== POLICY_VERSION) {
152
+ throw new PolicyError(`v must be ${POLICY_VERSION}`);
153
+ }
154
+ const addCredentialPaths = document['addCredentialPaths'] === undefined
155
+ ? []
156
+ : (Array.isArray(document['addCredentialPaths'])
157
+ ? document['addCredentialPaths'].map(parseCredentialPathEntry)
158
+ : (() => { throw new PolicyError('addCredentialPaths must be a list'); })());
159
+ const addEgressTools = document['addEgressTools'] === undefined
160
+ ? []
161
+ : requireStringArray(document['addEgressTools'], 'addEgressTools');
162
+ const raiseSeverity = new Map();
163
+ if (document['raiseSeverity'] !== undefined) {
164
+ for (const [ruleId, value] of Object.entries(requireObject(document['raiseSeverity'], 'raiseSeverity'))) {
165
+ const rule = SYNC_RULES.find(candidate => candidate.id === ruleId);
166
+ if (rule === undefined)
167
+ throw new PolicyError(`raiseSeverity names an unknown rule: ${ruleId}`);
168
+ const severity = requireSeverity(value, `raiseSeverity.${ruleId}`);
169
+ if (severityRank(severity) < severityRank(rule.severity)) {
170
+ throw new PolicyError(`raiseSeverity.${ruleId} would lower ${rule.severity} to ${severity};`
171
+ + ' a repo-local policy may only tighten');
172
+ }
173
+ raiseSeverity.set(ruleId, severity);
174
+ }
175
+ }
176
+ const enable = requireStringArray(document['enable'] ?? [], 'enable');
177
+ for (const toggle of enable) {
178
+ if (!ENABLEABLE.includes(toggle)) {
179
+ throw new PolicyError(`enable names an unknown toggle: ${toggle}. Known toggles: ${ENABLEABLE.join(', ')}`);
180
+ }
181
+ }
182
+ return { addCredentialPaths, addEgressTools, raiseSeverity, enable: enable };
183
+ }
184
+ /**
185
+ * Read a repo-local policy file from disk.
186
+ *
187
+ * Absence is not a misconfiguration here, which is the one place this package
188
+ * departs from "never silently skip a missing referent": `policyFile` names a
189
+ * path inside the *workspace*, and the recommended value is workspace-relative,
190
+ * so most repositories will not have one. Failing the mount would refuse to
191
+ * start `dsh` in every repository lacking the file — and would hand a hostile
192
+ * repository a way to remove the guard floor by deleting or breaking it.
193
+ * A malformed file is loud and ignored, never obeyed in part.
194
+ * @param path - the file to read.
195
+ * @returns the validated policy, its absence, or the problem to report.
196
+ */
197
+ export function loadRepoPolicy(path) {
198
+ let text;
199
+ try {
200
+ text = readFileSync(path, 'utf8');
201
+ }
202
+ catch (error) {
203
+ if (error.code === 'ENOENT')
204
+ return { kind: 'absent' };
205
+ return { kind: 'invalid', problem: `cannot read ${path}: ${String(error)}` };
206
+ }
207
+ try {
208
+ return { kind: 'loaded', policy: parseRepoPolicy(text) };
209
+ }
210
+ catch (error) {
211
+ return { kind: 'invalid', problem: String(error) };
212
+ }
213
+ }
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
+ /**
219
+ * Deny rules protecting this plugin's own state.
220
+ *
221
+ * Every one of these is known at mount: the key file whose bytes make a
222
+ * placeholder hash keyed rather than a bare digest, the append-only sink that
223
+ * is the only evidence a decision happened, and the harness home holding the
224
+ * provider credentials, the session logs and the profiles that decide which
225
+ * plugins load at all.
226
+ * @param config - the deployment-controlled configuration.
227
+ * @param dshHome - the resolved harness home.
228
+ * @returns rules appended after the built-in table.
229
+ */
230
+ function selfProtectionRules(config, dshHome) {
231
+ return [
232
+ { id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePattern(resolve(config.redactionKeyFile))}$`, 'i') },
233
+ { 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') },
235
+ ];
236
+ }
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
+ /**
250
+ * Merge the deployment config with an optional repo-local policy.
251
+ * @param config - the deployment-controlled configuration.
252
+ * @param repo - the repo-local policy, when one is mounted.
253
+ * @returns the effective policy every seam reads.
254
+ */
255
+ export function resolvePolicy(config, repo) {
256
+ const enabled = (toggle) => config[toggle] || (repo?.enable.includes(toggle) ?? false);
257
+ return {
258
+ credentialPathRules: [
259
+ ...CREDENTIAL_PATH_RULES,
260
+ ...selfProtectionRules(config, resolveDshHome()),
261
+ ...repo?.addCredentialPaths ?? [],
262
+ ],
263
+ extraEgressTools: new Set(repo?.addEgressTools ?? []),
264
+ syncRules: SYNC_RULES.map((rule) => {
265
+ const raised = repo?.raiseSeverity.get(rule.id);
266
+ return raised === undefined ? rule : { ...rule, severity: raised };
267
+ }),
268
+ maxScanBytes: config.maxScanBytes,
269
+ breadthTier: enabled('breadthTier'),
270
+ resultRedaction: enabled('resultRedaction'),
271
+ telemetryRedaction: enabled('telemetryRedaction'),
272
+ redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
273
+ };
274
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Turning {@link Detection}s into redacted text, and the keyed hash that lets
3
+ * an operator correlate two placeholders without the plugin ever writing the
4
+ * secret.
5
+ *
6
+ * Two properties this module exists to hold:
7
+ *
8
+ * - **Spans are expanded before they are spliced.** Tier-2 spans are advisory
9
+ * and verified to under-cover a secret, so every span grows outward to
10
+ * whitespace boundaries and overlapping spans merge. Over-redaction is the
11
+ * safe direction.
12
+ * - **The placeholder is deterministic.** `HMAC-SHA256(key, span)` truncated
13
+ * to 12 hex characters — stable for the same secret under the same
14
+ * installation key, useless to anyone without the key.
15
+ * @module dsh-dlp/redaction
16
+ */
17
+ import { createHmac } from 'node:crypto';
18
+ import { severityRank } from "./detectors.js";
19
+ /** Number of hex characters kept from the HMAC digest. */
20
+ const HASH_LENGTH = 12;
21
+ /**
22
+ * Mints the keyed hashes that appear in placeholders and audit records. Holds
23
+ * the installation key; the key never leaves this object.
24
+ */
25
+ export class SpanHasher {
26
+ #key;
27
+ /**
28
+ * @param key - installation secret, at least 16 bytes, read from `redactionKeyFile`.
29
+ */
30
+ constructor(key) {
31
+ if (key.length < 16)
32
+ throw new Error('dsh-dlp: redaction key must be at least 16 bytes');
33
+ this.#key = Buffer.from(key);
34
+ }
35
+ /**
36
+ * Keyed hash of one replaced region.
37
+ * @param text - the exact text being replaced.
38
+ * @returns 12 lowercase hex characters.
39
+ */
40
+ hash(text) {
41
+ return createHmac('sha256', this.#key).update(text, 'utf8').digest('hex').slice(0, HASH_LENGTH);
42
+ }
43
+ }
44
+ /**
45
+ * Shorten a rule id for the placeholder. Secretlint's package-qualified ids
46
+ * are long enough to dominate the replacement text.
47
+ * @param ruleId - the full rule identity.
48
+ * @returns the readable tail of the id.
49
+ */
50
+ export function shortRuleId(ruleId) {
51
+ return ruleId
52
+ .replace('@secretlint/secretlint-rule-', '')
53
+ .replace('dsh-dlp/', '');
54
+ }
55
+ /**
56
+ * The text substituted for one redacted span.
57
+ * @param span - the span being replaced.
58
+ * @returns a stable placeholder carrying the rule and the keyed hash.
59
+ */
60
+ export function placeholderFor(span) {
61
+ return `[REDACTED:dsh-dlp:${shortRuleId(span.ruleId)}:${span.hash}]`;
62
+ }
63
+ /**
64
+ * Characters that end a secret. Whitespace alone is not enough: one detection
65
+ * inside a line of minified JSON would expand to the whole line and destroy
66
+ * every other field on it, so quotes and the JSON, query-string and
67
+ * assignment separators bound a span too.
68
+ */
69
+ const SPAN_DELIMITERS = /[\s"'`=:,;&?<>(){}[\]]/;
70
+ /** Grow one span outward until both edges sit on a delimiter or a string boundary. */
71
+ function expand(text, start, end) {
72
+ let left = Math.max(0, start);
73
+ let right = Math.min(text.length, end);
74
+ while (left > 0 && !SPAN_DELIMITERS.test(text.charAt(left - 1)))
75
+ left -= 1;
76
+ while (right < text.length && !SPAN_DELIMITERS.test(text.charAt(right)))
77
+ right += 1;
78
+ return { start: left, end: right };
79
+ }
80
+ /** Detections merged into non-overlapping regions, each attributed to its strictest rule. */
81
+ function mergeSpans(text, detections) {
82
+ const expanded = detections
83
+ .map(detection => ({ ...detection, ...expand(text, detection.start, detection.end) }))
84
+ .sort((left, right) => left.start - right.start || left.end - right.end);
85
+ const merged = [];
86
+ for (const candidate of expanded) {
87
+ const previous = merged.at(-1);
88
+ if (previous === undefined || candidate.start > previous.end) {
89
+ merged.push(candidate);
90
+ continue;
91
+ }
92
+ const stricter = severityRank(candidate.severity) > severityRank(previous.severity);
93
+ merged[merged.length - 1] = {
94
+ ruleId: stricter ? candidate.ruleId : previous.ruleId,
95
+ ruleVersion: stricter ? candidate.ruleVersion : previous.ruleVersion,
96
+ severity: stricter ? candidate.severity : previous.severity,
97
+ start: previous.start,
98
+ end: Math.max(previous.end, candidate.end),
99
+ };
100
+ }
101
+ return merged;
102
+ }
103
+ /**
104
+ * Replace every detected region of one string with its placeholder.
105
+ * @param text - the string to redact.
106
+ * @param detections - matches reported by either detection tier.
107
+ * @param hasher - mints each span's keyed hash.
108
+ * @param path - JSON pointer recorded on every span, when scanning a structure.
109
+ * @returns the redacted string and the spans that were replaced.
110
+ */
111
+ export function redactText(text, detections, hasher, path) {
112
+ if (detections.length === 0)
113
+ return { text, spans: [] };
114
+ const spans = [];
115
+ const pieces = [];
116
+ let cursor = 0;
117
+ for (const region of mergeSpans(text, detections)) {
118
+ const span = {
119
+ ...region,
120
+ hash: hasher.hash(text.slice(region.start, region.end)),
121
+ ...path === undefined ? {} : { path },
122
+ };
123
+ pieces.push(text.slice(cursor, span.start), placeholderFor(span));
124
+ spans.push(span);
125
+ cursor = span.end;
126
+ }
127
+ pieces.push(text.slice(cursor));
128
+ return { text: pieces.join(''), spans };
129
+ }
130
+ /**
131
+ * Every string reachable inside a value, at any depth. Object keys are not
132
+ * included: a key is structure, not payload.
133
+ * @param value - parsed tool arguments, a tool's canonical output, or any JSON value.
134
+ * @returns each string found, in traversal order.
135
+ */
136
+ export function nestedStrings(value) {
137
+ const found = [];
138
+ const walk = (node) => {
139
+ if (typeof node === 'string') {
140
+ found.push(node);
141
+ return;
142
+ }
143
+ if (Array.isArray(node)) {
144
+ for (const item of node)
145
+ walk(item);
146
+ return;
147
+ }
148
+ if (typeof node === 'object' && node !== null) {
149
+ for (const item of Object.values(node))
150
+ walk(item);
151
+ }
152
+ };
153
+ walk(value);
154
+ return found;
155
+ }
156
+ /** Escape one JSON pointer segment per RFC 6901. */
157
+ function pointerSegment(segment) {
158
+ return segment.replace(/~/g, '~0').replace(/\//g, '~1');
159
+ }
160
+ /**
161
+ * Redact every string inside a JSON value, at any depth.
162
+ *
163
+ * Object *keys* are left alone: a key is structure, not payload, and renaming
164
+ * one would break the owning tool's `output.schema` on re-validation.
165
+ * @param value - the structure to redact.
166
+ * @param scan - synchronous detector applied to each string.
167
+ * @param hasher - mints each span's keyed hash.
168
+ * @returns the redacted structure, the spans replaced, and whether anything changed.
169
+ */
170
+ export function redactJson(value, scan, hasher) {
171
+ const spans = [];
172
+ let changed = false;
173
+ const walk = (node, path) => {
174
+ if (typeof node === 'string') {
175
+ const redacted = redactText(node, scan(node), hasher, path);
176
+ if (redacted.spans.length === 0)
177
+ return node;
178
+ changed = true;
179
+ spans.push(...redacted.spans);
180
+ return redacted.text;
181
+ }
182
+ if (Array.isArray(node)) {
183
+ return node.map((item, index) => walk(item, `${path}/${index}`));
184
+ }
185
+ if (typeof node === 'object' && node !== null) {
186
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, walk(item, `${path}/${pointerSegment(key)}`)]));
187
+ }
188
+ return node;
189
+ };
190
+ const result = walk(value, '');
191
+ return { value: result, spans, changed };
192
+ }
193
+ /**
194
+ * Redact the text blocks of one model-facing content array. Non-text blocks
195
+ * pass through: this plugin has no detector for image or audio payloads and
196
+ * silently dropping them would be worse than leaving them.
197
+ * @param blocks - the content blocks to redact.
198
+ * @param scan - synchronous detector applied to each block's text.
199
+ * @param hasher - mints each span's keyed hash.
200
+ * @returns the redacted blocks, the spans replaced, and whether anything changed.
201
+ */
202
+ export function redactContent(blocks, scan, hasher) {
203
+ const spans = [];
204
+ let changed = false;
205
+ const content = blocks.map((block, index) => {
206
+ if (block.type !== 'text')
207
+ return block;
208
+ const redacted = redactText(block.text, scan(block.text), hasher, `/${index}/text`);
209
+ if (redacted.spans.length === 0)
210
+ return block;
211
+ changed = true;
212
+ spans.push(...redacted.spans);
213
+ return { ...block, text: redacted.text };
214
+ });
215
+ return { content, spans, changed };
216
+ }