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/results.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The two result-side seams: the async argument breadth tier at
3
+ * `tools/pre-execute`, and result redaction at `tools/post-execute`.
4
+ *
5
+ * Both are best-effort by construction. A `tools/pre-execute` listener
6
+ * registered ahead of ours can return without calling `next()` and neutralize
7
+ * the breadth tier; a `tools/post-execute` listener ahead of ours can replace a
8
+ * result after we redacted it. Only `ctx.tools.guard()` is order-independent.
9
+ * What these seams buy is breadth: they can await, so `@secretlint/core`'s
10
+ * whole rule set applies here and not in the guard.
11
+ * @module dsh-dlp/results
12
+ */
13
+ import { DENY_SEVERITY, scanAll, scanSync, severityRank, } from "./detectors.js";
14
+ import { isEgressCapable } from "./paths.js";
15
+ import { nestedStrings, redactContent, redactJson } from "./redaction.js";
16
+ /**
17
+ * Separator the strings of one result are rendered with before the
18
+ * cross-string scan. A newline is what the reader of a tool result sees
19
+ * between two lines of a file, and it keeps every `\b` and `^` anchor a
20
+ * per-string scan would have honoured.
21
+ */
22
+ const RENDER_SEPARATOR = '\n';
23
+ /**
24
+ * Scan a set of strings once and hand back a synchronous lookup, so the
25
+ * redaction walkers stay synchronous while detection stays async.
26
+ *
27
+ * The strings are scanned twice over: each on its own by tier 1, and all of
28
+ * them joined by both tiers. The joined pass is what finds a secret split
29
+ * across strings — a PEM block arriving as one `lines[i].text` per line, a
30
+ * token spanning two content blocks — which no per-string walk can see; its
31
+ * offsets are then mapped back onto the individual strings, because that is
32
+ * what the redaction walkers splice.
33
+ *
34
+ * Tier 2 runs once, over the joined text, and is budgeted by characters
35
+ * through `maxScanBytes`. One `lintSource` call per string would multiply a
36
+ * fixed per-call cost by however many pieces the tool happened to split its
37
+ * output into, and a budget counted in strings would make how much of a result
38
+ * is scanned depend on the same accident.
39
+ * @param strings - every string that will be redacted, in render order.
40
+ * @param policy - the effective policy.
41
+ * @returns a memoized lookup and whether tier 2 saw less than the whole rendering.
42
+ */
43
+ async function prepareScan(strings, policy) {
44
+ const rendered = strings.join(RENDER_SEPARATOR);
45
+ const { detections, truncated } = await scanAll(rendered, policy.syncRules, policy.maxScanBytes);
46
+ const memo = new Map();
47
+ const found = (text) => {
48
+ const existing = memo.get(text);
49
+ if (existing !== undefined)
50
+ return existing;
51
+ const created = [...scanSync(text, policy.syncRules).detections];
52
+ memo.set(text, created);
53
+ return created;
54
+ };
55
+ let offset = 0;
56
+ for (const text of strings) {
57
+ const start = offset;
58
+ const end = start + text.length;
59
+ offset = end + RENDER_SEPARATOR.length;
60
+ const local = found(text);
61
+ for (const detection of detections) {
62
+ if (detection.end <= start || detection.start >= end)
63
+ continue;
64
+ local.push({
65
+ ...detection,
66
+ start: Math.max(0, detection.start - start),
67
+ end: Math.min(text.length, detection.end - start),
68
+ });
69
+ }
70
+ }
71
+ /* v8 ignore next -- every string handed to the walkers was collected for this memo. */
72
+ return { scan: text => memo.get(text) ?? [], truncated };
73
+ }
74
+ /** Text carried by a content array's text blocks. */
75
+ function contentStrings(blocks) {
76
+ return blocks.flatMap(block => block.type === 'text' ? [block.text] : []);
77
+ }
78
+ /**
79
+ * Strings the harness keeps in the durable result when the decision does not
80
+ * replace the canonical value.
81
+ *
82
+ * `tools/post-execute` returning `accept{content}` leaves `{...result}` in
83
+ * place, so both `value` and the `presentationMeta()` projection reach
84
+ * `session.append('tool/result', ...)` exactly as the tool produced them. They
85
+ * are never model-visible and therefore never redacted by a content
86
+ * replacement — which is why a content arm is not an option for a dirty
87
+ * success.
88
+ * @param result - the dispatch outcome the waterfall was called with.
89
+ * @returns every string that would be persisted verbatim.
90
+ */
91
+ function persistedStrings(result) {
92
+ return [
93
+ ...result.isError ? [] : nestedStrings(result.value),
94
+ ...result.meta === undefined ? [] : nestedStrings(result.meta),
95
+ ];
96
+ }
97
+ /** Feedback for a result this plugin refuses to let through in any arm. */
98
+ function withheldFeedback(spans) {
99
+ const rules = [...new Set(spans.map(span => span.ruleId))].join(', ');
100
+ const hashes = [...new Set(spans.map(span => span.hash))].join(', ');
101
+ return [{
102
+ type: 'text',
103
+ text: 'dsh-dlp withheld this tool result: it carries credential material '
104
+ + `(rule ${rules}, keyed hash ${hashes}) in a part of the result that cannot be rewritten without `
105
+ + 'discarding it. Do not retry the same call. Ask the user for the value you need, or work from a source '
106
+ + 'that is not a credential store.',
107
+ }];
108
+ }
109
+ /**
110
+ * Redact whatever the downstream decision settled on.
111
+ *
112
+ * Arm selection follows what each arm can actually clean:
113
+ *
114
+ * - `accept{value}` re-validates `output.schema`, re-runs `output.render()`
115
+ * and re-derives `presentationMeta()`, so one replacement redacts the
116
+ * canonical value, the model-facing content and the persisted meta together.
117
+ * It is the only arm that keeps a secret out of the durable log, so every
118
+ * successful result with anything to redact takes it.
119
+ * - `accept{content}` replaces presentation only. It is used when the
120
+ * persisted surfaces are already clean — a failed result, which has no
121
+ * value, or a success whose secret exists only in the rendered content.
122
+ * - `block` is the fallback when neither works: a failed result whose `meta`
123
+ * carries a secret, or a value that still scans dirty after redaction.
124
+ * Blocking replaces the whole result, which is the only way to drop `meta`.
125
+ *
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.
130
+ *
131
+ * A downstream `accept{content}` over a dirty value is overruled by the value
132
+ * arm, which discards that listener's presentation choice. Keeping it would
133
+ * put the value in the session log; the harness re-renders from the redacted
134
+ * value instead.
135
+ * @param decision - what the rest of the waterfall returned.
136
+ * @param result - the dispatch outcome the waterfall was called with.
137
+ * @param policy - the effective policy.
138
+ * @param hasher - mints each span's keyed hash.
139
+ * @returns the decision to return, the spans replaced, and scan completeness.
140
+ */
141
+ export async function redactDecision(decision, result, policy, hasher) {
142
+ if (decision.kind === 'block') {
143
+ const prepared = await prepareScan(contentStrings(decision.feedback), policy);
144
+ const redacted = redactContent(decision.feedback, prepared.scan, hasher);
145
+ return {
146
+ decision: redacted.changed ? { ...decision, feedback: redacted.content } : decision,
147
+ spans: redacted.spans,
148
+ truncatedScan: prepared.truncated,
149
+ };
150
+ }
151
+ const replacedValue = Object.hasOwn(decision, 'value') ? decision.value : undefined;
152
+ const replacedContent = Object.hasOwn(decision, 'content') ? decision.content : undefined;
153
+ const contexts = decision.additionalContexts === undefined
154
+ ? {}
155
+ : { additionalContexts: decision.additionalContexts };
156
+ // The value the harness will persist: a downstream replacement when there is
157
+ // one, otherwise the tool's own. A failed result has no value at all.
158
+ const value = replacedValue ?? (result.isError ? undefined : result.value);
159
+ const visible = contentStrings(replacedContent ?? result.content);
160
+ const persisted = replacedValue === undefined
161
+ ? persistedStrings(result)
162
+ : [...nestedStrings(replacedValue), ...result.meta === undefined ? [] : nestedStrings(result.meta)];
163
+ const prepared = await prepareScan([...persisted, ...visible], policy);
164
+ const dirty = (strings) => strings.some(text => prepared.scan(text).length > 0);
165
+ if (value !== undefined && dirty(nestedStrings(value))) {
166
+ const redacted = redactJson(value, prepared.scan, hasher);
167
+ const remaining = nestedStrings(redacted.value);
168
+ const residual = await prepareScan(remaining, policy);
169
+ if (remaining.some(text => residual.scan(text).length > 0)) {
170
+ return {
171
+ decision: { kind: 'block', feedback: withheldFeedback(redacted.spans) },
172
+ spans: redacted.spans,
173
+ truncatedScan: prepared.truncated || residual.truncated,
174
+ };
175
+ }
176
+ return {
177
+ decision: { kind: 'accept', value: redacted.value, ...contexts },
178
+ spans: redacted.spans,
179
+ truncatedScan: prepared.truncated,
180
+ };
181
+ }
182
+ // The value is clean, so the durable result is clean unless `meta` — which
183
+ // no accept arm can rewrite — carries something of its own.
184
+ if (result.meta !== undefined && dirty(nestedStrings(result.meta))) {
185
+ const spans = redactJson(result.meta, prepared.scan, hasher).spans;
186
+ return {
187
+ decision: { kind: 'block', feedback: withheldFeedback(spans) },
188
+ spans,
189
+ truncatedScan: prepared.truncated,
190
+ };
191
+ }
192
+ const blocks = replacedContent ?? result.content;
193
+ const redacted = redactContent(blocks, prepared.scan, hasher);
194
+ if (!redacted.changed) {
195
+ return { decision, spans: [], truncatedScan: prepared.truncated };
196
+ }
197
+ return {
198
+ decision: { kind: 'accept', content: redacted.content, ...contexts },
199
+ spans: redacted.spans,
200
+ truncatedScan: prepared.truncated,
201
+ };
202
+ }
203
+ /**
204
+ * Decide whether the breadth tier denies one call before dispatch.
205
+ *
206
+ * Only ever narrows: the caller has already delegated, and a decision that is
207
+ * not `allow` is returned untouched.
208
+ * @param exec - the pending call.
209
+ * @param policy - the effective policy.
210
+ * @param hasher - mints the keyed hashes quoted in a denial reason.
211
+ * @returns the denial reason and its spans, or `undefined` to leave the call allowed.
212
+ */
213
+ export async function evaluateBreadthTier(exec, policy, hasher) {
214
+ if (!isEgressCapable(exec.name, policy.extraEgressTools))
215
+ return undefined;
216
+ const spans = [];
217
+ for (const text of new Set(nestedStrings(exec.arguments))) {
218
+ const { detections } = await scanAll(text, policy.syncRules, policy.maxScanBytes);
219
+ for (const detection of detections) {
220
+ if (severityRank(detection.severity) < severityRank(DENY_SEVERITY))
221
+ continue;
222
+ spans.push({ ...detection, hash: hasher.hash(text.slice(detection.start, detection.end)) });
223
+ }
224
+ }
225
+ if (spans.length === 0)
226
+ return undefined;
227
+ const ruleIds = [...new Set(spans.map(span => span.ruleId))];
228
+ return {
229
+ reason: `dsh-dlp denied ${JSON.stringify(exec.name)}: its arguments contain credential material matching `
230
+ + `${ruleIds.join(', ')} (keyed hash ${spans.map(span => span.hash).join(', ')}). `
231
+ + 'Remove the credential from the call.',
232
+ spans,
233
+ };
234
+ }
235
+ /** A `PreToolDecision` denial built from a breadth-tier finding. */
236
+ export function breadthTierDenial(reason) {
237
+ return { kind: 'deny', reason };
238
+ }
package/lib/sink.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * This plugin's own durable output, and the call correlation that makes a
3
+ * record locatable.
4
+ *
5
+ * Nothing here touches the session log. `Session.append()` offers no way to
6
+ * set the envelope's `ignorable` flag, so an out-of-repo event type is written
7
+ * without it and the user's next resume throws `SessionFormatUnsupportedError`
8
+ * and refuses the whole session. The plugin is therefore read-side with
9
+ * respect to the log, and every durable record goes to the JSONL file named by
10
+ * `auditLog`.
11
+ *
12
+ * Because the `SessionEvent` envelope carries only `type`, `seq`, `time` and
13
+ * `data`, each record carries its own identity: `sessionId`, `turn`, `step`,
14
+ * `callId`, and a producer-minted `decisionId`.
15
+ * @module dsh-dlp/sink
16
+ */
17
+ import { appendFileSync } from 'node:fs';
18
+ import { randomUUID } from 'node:crypto';
19
+ /**
20
+ * Mint a decision id.
21
+ * @returns an id unique to one guard verdict or redaction pass.
22
+ */
23
+ export function newDecisionId() {
24
+ return `dlp-${randomUUID()}`;
25
+ }
26
+ /** Payload version carried inside every record this plugin writes. */
27
+ export const RECORD_VERSION = 1;
28
+ /** Append-only JSONL sink for this plugin's decisions. */
29
+ export class AuditSink {
30
+ #path;
31
+ #onFailure;
32
+ /**
33
+ * @param path - absolute path of the JSONL file to append to.
34
+ * @param onFailure - notified when a write fails; a broken sink never changes a verdict.
35
+ */
36
+ constructor(path, onFailure) {
37
+ this.#path = path;
38
+ this.#onFailure = onFailure;
39
+ }
40
+ /**
41
+ * Append one record.
42
+ *
43
+ * A write failure is reported and swallowed on purpose: the sink is
44
+ * evidence, not enforcement, and letting a full disk turn every tool call
45
+ * into a denial trades a confidentiality control for an availability
46
+ * outage. A guard that throws would also skip `tools/post-execute` and so
47
+ * disable redaction for that call.
48
+ * @param record - the decision to record.
49
+ */
50
+ write(record) {
51
+ try {
52
+ appendFileSync(this.#path, `${JSON.stringify(record)}\n`);
53
+ }
54
+ catch (error) {
55
+ this.#onFailure(error);
56
+ }
57
+ }
58
+ }
59
+ /**
60
+ * Remembers where each in-flight tool call sits in the session.
61
+ *
62
+ * `Agent` exposes no turn or step, and the tool pipeline hands listeners only
63
+ * a `ToolExecution`. The `tool/call` session event carries `turn`, `step` and
64
+ * `callId` together, so following the session firehose is the only way to
65
+ * label a record with its position.
66
+ */
67
+ export class CallCorrelator {
68
+ #positions = new Map();
69
+ #limit;
70
+ /**
71
+ * @param limit - maximum remembered calls; the oldest entry is dropped past it.
72
+ */
73
+ constructor(limit = 512) {
74
+ this.#limit = limit;
75
+ }
76
+ /**
77
+ * Record one call's position.
78
+ * @param callId - the call's id from the `tool/call` event.
79
+ * @param position - the turn and step that event reported.
80
+ */
81
+ note(callId, position) {
82
+ this.#positions.set(callId, position);
83
+ if (this.#positions.size > this.#limit) {
84
+ const oldest = this.#positions.keys().next();
85
+ /* v8 ignore next -- reached only past the limit, so the map is never empty here. */
86
+ if (!oldest.done)
87
+ this.#positions.delete(oldest.value);
88
+ }
89
+ }
90
+ /**
91
+ * Forget one call.
92
+ * @param callId - the call whose result has been committed.
93
+ */
94
+ forget(callId) {
95
+ this.#positions.delete(callId);
96
+ }
97
+ /**
98
+ * Look one call's position up.
99
+ * @param callId - the call to locate.
100
+ * @returns its turn and step, or `undefined` when the call was never seen.
101
+ */
102
+ lookup(callId) {
103
+ return this.#positions.get(callId);
104
+ }
105
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Fail-closed redaction for exported telemetry.
3
+ *
4
+ * `DSH_TELEMETRY_MODE=FULL` mounts the OTel backend with a live coordinator
5
+ * that exports a deep copy of every session event's `data` — user and
6
+ * assistant message text, tool arguments, tool results — plus identity
7
+ * attributes including `session.cwd`. The `session-telemetry/record` waterfall
8
+ * is the documented redaction seam and it ships **no rules of its own**: with
9
+ * nothing mounted, records reach the exporter exactly as captured. This
10
+ * listener is the missing rule set.
11
+ *
12
+ * The waterfall is synchronous (`next: () => SessionTelemetryRecord`), so only
13
+ * tier 1 is reachable here — there is no seam on this path that can await, and
14
+ * a secret only `@secretlint/core` recognises survives it. It is fail-closed
15
+ * by construction: the coordinator dispatches inside its own containment, and a
16
+ * throwing listener withholds that one record and never reaches the agent
17
+ * loop. This module therefore throws rather than returning a record it could
18
+ * not fully process.
19
+ *
20
+ * Records mirroring `tool/result` have already been through
21
+ * `tools/post-execute` redaction, which runs before the event is appended —
22
+ * but only for what that seam could reach, so this listener re-scans every
23
+ * record rather than trusting the event type. The value it adds beyond that is
24
+ * on `user/message`, `assistant/message`, `tool/call` arguments, and the
25
+ * workspace path.
26
+ * @module dsh-dlp/telemetry
27
+ */
28
+ import { scanSync } from "./detectors.js";
29
+ import { placeholderFor, redactJson, redactText } from "./redaction.js";
30
+ /** Attribute keys whose values are filesystem paths rather than payload text. */
31
+ const PATH_ATTRIBUTES = ['session.cwd'];
32
+ /** Rule identity recorded when a workspace path is replaced. */
33
+ export const WORKSPACE_PATH_RULE = 'dsh-dlp/telemetry-workspace-path';
34
+ /**
35
+ * Redact one outbound telemetry record.
36
+ *
37
+ * The record is treated as data, not as a typed structure: `body` is the
38
+ * event's own `data`, whose shape is owned by whichever package declared the
39
+ * event, and new event types appear without this plugin knowing them. Walking
40
+ * it as JSON is what makes the listener total.
41
+ * @param record - the candidate record, already the coordinator's own deep copy.
42
+ * @param policy - the effective policy.
43
+ * @param hasher - mints each span's keyed hash.
44
+ * @returns the record to hand onward and the spans replaced.
45
+ */
46
+ export function redactRecord(record, policy, hasher) {
47
+ const spans = [];
48
+ const scan = (text) => scanSync(text, policy.syncRules).detections;
49
+ // `body` is `unknown` on the seam but is always the append-time-validated,
50
+ // JSON-serializable `data` of a session event.
51
+ const body = redactJson(record.body, scan, hasher);
52
+ spans.push(...body.spans);
53
+ const attributes = {};
54
+ for (const [key, value] of Object.entries(record.attributes)) {
55
+ if (typeof value !== 'string') {
56
+ attributes[key] = value;
57
+ continue;
58
+ }
59
+ if (policy.redactTelemetryWorkspacePaths && PATH_ATTRIBUTES.includes(key)) {
60
+ const span = {
61
+ ruleId: WORKSPACE_PATH_RULE,
62
+ ruleVersion: 1,
63
+ severity: 'medium',
64
+ start: 0,
65
+ end: value.length,
66
+ hash: hasher.hash(value),
67
+ path: `/attributes/${key}`,
68
+ };
69
+ spans.push(span);
70
+ attributes[key] = placeholderFor(span);
71
+ continue;
72
+ }
73
+ const redacted = redactText(value, scan(value), hasher, `/attributes/${key}`);
74
+ spans.push(...redacted.spans);
75
+ attributes[key] = redacted.text;
76
+ }
77
+ if (spans.length === 0)
78
+ return { record, spans };
79
+ return { record: { ...record, body: body.value, attributes }, spans };
80
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Two detection tiers and the vocabulary they share.
3
+ *
4
+ * Tier 1 is a synchronous table of prefix-anchored token formats, owned here
5
+ * because two of the three seams this plugin uses are synchronous:
6
+ * `ToolGuard` returns `string | undefined` and the `session-telemetry/record`
7
+ * waterfall returns a record, neither of which can await. Tier 2 wraps
8
+ * `@secretlint/core`, which runs in-process with no subprocess but resolves a
9
+ * promise, so it is reachable only from `tools/pre-execute` and
10
+ * `tools/post-execute`.
11
+ *
12
+ * Neither tier ever returns the matched text. A {@link Detection} carries
13
+ * offsets; turning offsets into a keyed hash is `redaction.ts`'s job.
14
+ * @module dsh-dlp/detectors
15
+ */
16
+ /**
17
+ * How badly a match should be treated. Ordered: {@link severityRank} compares
18
+ * two values, and the repo-local policy tier may only move a rule upward.
19
+ */
20
+ export type Severity = 'low' | 'medium' | 'high' | 'critical';
21
+ /**
22
+ * Position of a severity in the ordering.
23
+ * @param severity - the value to rank.
24
+ * @returns its index in the ascending order; higher means stricter.
25
+ */
26
+ export declare function severityRank(severity: Severity): number;
27
+ /** Severity at or above which the guard floor denies rather than only redacting. */
28
+ export declare const DENY_SEVERITY: Severity;
29
+ /** One match, described without disclosing what matched. */
30
+ export interface Detection {
31
+ /** Rule identity, stable across versions of this package. */
32
+ readonly ruleId: string;
33
+ /** Bumped whenever the rule's pattern changes, so an old audit record stays interpretable. */
34
+ readonly ruleVersion: number;
35
+ readonly severity: Severity;
36
+ /** Inclusive start offset into the scanned string. */
37
+ readonly start: number;
38
+ /** Exclusive end offset into the scanned string. */
39
+ readonly end: number;
40
+ }
41
+ /** Outcome of one scan. */
42
+ export interface ScanResult {
43
+ readonly detections: readonly Detection[];
44
+ /** Whether tier 2 saw only part of the input because of the byte cap. */
45
+ readonly truncated: boolean;
46
+ }
47
+ /** One synchronous rule in tier 1. */
48
+ export interface SyncRule {
49
+ readonly id: string;
50
+ readonly version: number;
51
+ readonly severity: Severity;
52
+ /** Global-flagged; matched through `matchAll`, which never mutates this instance's `lastIndex`. */
53
+ readonly pattern: RegExp;
54
+ }
55
+ /**
56
+ * Tier 1's rule table. Deliberately narrow: only formats whose prefix or
57
+ * delimiters make a match structurally unambiguous, plus PEM blocks and
58
+ * credential-bearing URLs. Anything requiring entropy heuristics is left to
59
+ * tier 2, where a false positive costs a redaction rather than a denial.
60
+ */
61
+ export declare const SYNC_RULES: readonly SyncRule[];
62
+ /**
63
+ * Scan text with tier 1. Pure, synchronous, no I/O, and never capped: a table
64
+ * of anchored regular expressions costs a linear pass, so there is no reason
65
+ * to stop scanning where tier 2 has to. `truncated` is therefore always
66
+ * `false` here and only tier 2 can set it.
67
+ * @param text - the string to scan.
68
+ * @param rules - the rule table to apply; defaults to {@link SYNC_RULES}.
69
+ * @returns every match, ordered by start offset.
70
+ */
71
+ export declare function scanSync(text: string, rules?: readonly SyncRule[]): ScanResult;
72
+ /**
73
+ * Major version of the pinned `@secretlint/core` rule set, recorded as the
74
+ * rule version of every tier-2 detection. The exact dependency version is
75
+ * pinned in this package's manifest; the major is what changes a rule's
76
+ * meaning.
77
+ */
78
+ export declare const SECRETLINT_RULE_VERSION = 13;
79
+ /**
80
+ * Scan text with tier 2 (`@secretlint/core`, recommended preset). Runs
81
+ * in-process; no subprocess and no network.
82
+ *
83
+ * The reported spans are advisory. `@secretlint/secretlint-rule-aws` reports
84
+ * `[0, 40]` for `aws_secret_access_key = <40 chars>`, which covers the
85
+ * assignment prefix rather than the whole secret, so a caller must never
86
+ * splice a reported span directly — {@link redactText} expands every span to
87
+ * whitespace boundaries first.
88
+ * @param text - the string to scan.
89
+ * @param maxScanBytes - cap on scanned characters; input beyond it is not examined.
90
+ * @returns every match, ordered by start offset, and whether the input was capped.
91
+ */
92
+ export declare function scanWithSecretlint(text: string, maxScanBytes?: number): Promise<ScanResult>;
93
+ /**
94
+ * Run both tiers over one string and merge their detections. Tier 1 sees the
95
+ * whole string; only tier 2 is capped.
96
+ * @param text - the string to scan.
97
+ * @param rules - tier-1 rule table.
98
+ * @param maxScanBytes - cap on characters handed to tier 2.
99
+ * @returns the union of both tiers, ordered by start offset.
100
+ */
101
+ export declare function scanAll(text: string, rules?: readonly SyncRule[], maxScanBytes?: number): Promise<ScanResult>;
102
+ //# sourceMappingURL=detectors.d.ts.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The guard floor: the one part of this plugin that holds under attack.
3
+ *
4
+ * It tests the path-typed arguments of a call — never file content — against
5
+ * the credential table, resolving symlinks first, and the whole argument set
6
+ * against the tier-1 detectors when the tool can move data off the machine.
7
+ * The `command` arm inside that is advisory pattern-matching: tokenising a
8
+ * shell command line catches `cat ~/.ssh/id_rsa` and loses to one glob
9
+ * character. README.md says so plainly and so does this comment.
10
+ *
11
+ * `ctx.tools.guard()` takes a synchronous `(exec) => string | undefined`. It
12
+ * has no allow arm, so no registration order can turn a denial back into
13
+ * permission, and the first denial wins. It runs after the whole
14
+ * `tools/pre-execute` waterfall and after `ctx.approval`, which is why the
15
+ * floor lives here rather than in a listener: `exec.arguments` is deep-frozen
16
+ * but `exec` itself is not frozen until after execution, so a pre-execute
17
+ * listener can reassign `exec.arguments` or `exec.name` and succeed. The guard
18
+ * reads what every listener finally left behind.
19
+ *
20
+ * Registered on a plain context, never through `agent.ctx`: a global guard
21
+ * covers every agent, every `run_code` inner sub-call, and every subagent
22
+ * child, while an agent-scoped listener never sees a subagent child's calls
23
+ * because a child agent is a sibling, not a descendant.
24
+ *
25
+ * The floor is not configurable. Per CONVENTIONS, security invariants stay
26
+ * fixed; the repo-local policy tier may only add to its tables.
27
+ * @module dsh-dlp/guard
28
+ */
29
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
30
+ import type { ResolvedPolicy } from './policy.ts';
31
+ import { type RedactedSpan, type SpanHasher } from './redaction.ts';
32
+ /** Why the floor denied one call. */
33
+ export interface GuardVerdict {
34
+ readonly kind: 'credential-path' | 'secret-argument' | 'internal-fault';
35
+ /** Model-facing text; carries rule identities and keyed hashes, never matched values. */
36
+ readonly reason: string;
37
+ /** The offending regions, described by rule identity, offsets, and keyed hash only. */
38
+ readonly spans: readonly RedactedSpan[];
39
+ }
40
+ /**
41
+ * Decide whether the floor denies one call.
42
+ *
43
+ * Credential paths are denied for every tool, not only readers: a shell that
44
+ * can `cat` a key can also copy it. Only path-typed arguments are tested —
45
+ * running the table over every string matches file content and denies writing
46
+ * a `.gitignore` that mentions `.env`. Argument secrets are denied only for
47
+ * egress-capable tools, because denying a local editor for holding the text it
48
+ * was asked to write would break ordinary work without closing an exfiltration
49
+ * path.
50
+ * @param exec - the pending call as the guard stage sees it.
51
+ * @param policy - the effective policy after the tighten-only merge.
52
+ * @param hasher - mints the keyed hashes quoted in a denial reason.
53
+ * @returns the denial, or `undefined` to abstain.
54
+ */
55
+ export declare function evaluateGuard(exec: Pick<ToolExecution, 'name' | 'arguments'>, policy: ResolvedPolicy, hasher: SpanHasher): GuardVerdict | undefined;
56
+ /**
57
+ * Wrap {@link evaluateGuard} so an internal fault becomes a denial.
58
+ *
59
+ * A guard that throws fails the call closed *and* skips `tools/post-execute`,
60
+ * which would silently disable result redaction for that call. Converting the
61
+ * fault into a denial string keeps the post-execute stage running.
62
+ * @param exec - the pending call.
63
+ * @param policy - the effective policy.
64
+ * @param hasher - mints the keyed hashes quoted in a denial reason.
65
+ * @returns the denial, or `undefined` to abstain.
66
+ */
67
+ export declare function safeEvaluateGuard(exec: Pick<ToolExecution, 'name' | 'arguments'>, policy: ResolvedPolicy, hasher: SpanHasher): GuardVerdict | undefined;
68
+ //# sourceMappingURL=guard.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `dsh-dlp` — data-loss prevention for DeepSeek Harness.
3
+ *
4
+ * Four registrations, in descending order of how much they can be trusted:
5
+ *
6
+ * 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
7
+ * credential paths named in a path-typed argument and for secrets heading
8
+ * into an egress-capable tool. Order-independent, because the guard seam
9
+ * has no allow arm.
10
+ * 2. `tools/pre-execute` — the async breadth tier, which can await
11
+ * `@secretlint/core`. Neutralizable by any listener registered ahead of it.
12
+ * 3. `tools/post-execute` — result redaction, applied before the `tool/result`
13
+ * session event is appended, so the durable log records the redacted copy;
14
+ * a result that cannot be cleaned is withheld rather than accepted.
15
+ * 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
16
+ * reaching tier 1 only because the waterfall is synchronous.
17
+ *
18
+ * This plugin is not a containment boundary. It runs in-process at the agent's
19
+ * own uid; anything the agent can execute can read the same files the guard
20
+ * denies. See README.md.
21
+ * @module dsh-dlp
22
+ */
23
+ import type { Context } from '@deepseek-ai/cordis';
24
+ import { type Config } from './policy.ts';
25
+ export { Config } from './policy.ts';
26
+ /** Display metadata; labels the plugin in Cordis diagnostics. */
27
+ export declare const name = "dsh-dlp";
28
+ /** Services required before `apply` runs. */
29
+ export declare const inject: string[];
30
+ /**
31
+ * Read the installation's redaction key, creating it on first mount.
32
+ *
33
+ * The key makes every placeholder a keyed hash rather than a bare digest:
34
+ * without it, anyone holding a candidate secret could confirm it against the
35
+ * audit log.
36
+ * @param path - the key file named by `redactionKeyFile`.
37
+ * @returns the key bytes.
38
+ * @throws when an existing key file is too short to be a key.
39
+ */
40
+ export declare function loadOrCreateKey(path: string): Buffer;
41
+ /**
42
+ * Mount the plugin.
43
+ * @param ctx - the plugin's context; every registration is undone on unload.
44
+ * @param config - validated `cordis.yml` configuration.
45
+ */
46
+ export declare function apply(ctx: Context, config: Config): void;
47
+ //# sourceMappingURL=index.d.ts.map