dsh-dlp 0.8.2 → 0.10.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 +45 -12
- package/cordis.patch.yml +8 -0
- package/lib/detectors.js +152 -9
- package/lib/index.js +58 -5
- package/lib/policy.js +47 -1
- package/lib/results.js +2 -2
- package/lib/steps.js +124 -0
- package/lib/types/detectors.d.ts +49 -8
- package/lib/types/index.d.ts +12 -1
- package/lib/types/policy.d.ts +56 -2
- package/lib/types/results.d.ts +39 -0
- package/lib/types/sink.d.ts +9 -1
- package/lib/types/steps.d.ts +106 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -12,28 +12,42 @@ built as an out-of-repo plugin.
|
|
|
12
12
|
following symlinks first.
|
|
13
13
|
2. **Redacts secrets out of tool results** before the model reads them and before the session log
|
|
14
14
|
records them, withholding a result it cannot clean.
|
|
15
|
-
3. **Redacts secrets out of
|
|
15
|
+
3. **Redacts secrets out of the messages a step enters with** — the context a listener splices
|
|
16
|
+
in (the workspace `AGENTS.md`/`CLAUDE.md` chain, a captured tmux pane, a hook's
|
|
17
|
+
`additionalContext`, a skill body a `/name` token loaded) and the input the loop claimed from
|
|
18
|
+
the inbox that the user did not type (a `dsh-webhook` delivery's third-party payload, a
|
|
19
|
+
settled subagent result, an agent relay). All of it reaches the model and the durable log
|
|
20
|
+
through `agent/pre-step` without ever being a tool result. A message whose `source.kind` is
|
|
21
|
+
`user` is exempt below `aggressiveness: high`; at `high` the user's own typed prompt is
|
|
22
|
+
redacted too, because this plugin cannot know which provider the request is bound for. The
|
|
23
|
+
`agent/inbox/spliced` delivery record keeps a delivery's original text, which is deliberate —
|
|
24
|
+
it derives no model message, and an operator investigating a webhook incident needs to read
|
|
25
|
+
what was actually delivered.
|
|
26
|
+
4. **Redacts secrets out of exported telemetry**, closing a hole where `DSH_TELEMETRY_MODE=FULL`
|
|
16
27
|
ships message text, tool arguments, results and workspace paths in the clear.
|
|
17
|
-
|
|
28
|
+
5. **Detects payment card numbers** — issuer range, a length that issuer assigns, and a Luhn
|
|
29
|
+
check digit — so cardholder data does not reach a third-party model in a tool result, a
|
|
30
|
+
spliced file, exported telemetry, or (at `aggressiveness: high`) a prompt someone typed.
|
|
31
|
+
6. **Strips invisible characters that carry hidden instructions** — the Tags block, bidi
|
|
18
32
|
overrides, runs of variation selectors — and strips terminal control sequences from the audit
|
|
19
33
|
lane so a tool result cannot forge its own audit record.
|
|
20
|
-
|
|
34
|
+
7. **Neutralises remote markdown images in assistant output** and detects a tool call another
|
|
21
35
|
plugin rewrote after the session log recorded it.
|
|
22
|
-
|
|
36
|
+
8. **Asks before the agent writes a file that changes future behaviour** — agent settings and
|
|
23
37
|
hooks, `CLAUDE.md`, `.claude/rules/**` and the other agent rules directories, prompt
|
|
24
38
|
templates, `.vscode/tasks.json`, `.mcp.json`, git hooks, CI workflows, shell startup files,
|
|
25
39
|
`pnpm-workspace.yaml` — and before it writes a `*_BASE_URL` that would redirect a provider
|
|
26
40
|
credential.
|
|
27
|
-
|
|
41
|
+
9. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
|
|
28
42
|
`approval_mode: auto`, an `apply` whose approval is still pending. Both `ask` tiers are
|
|
29
43
|
prompts rather than controls: they live at `tools/pre-execute`, they can be neutralised, and
|
|
30
44
|
they abstain wherever the approval seam prompts nobody — which includes every install under
|
|
31
45
|
`DSH_PERMISSION_MODE=danger-full-access` and a stock headless install under any mode.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
10. **Writes an audit record for every decision.** A redaction or denial names the rule, its
|
|
47
|
+
version, the offsets and a keyed hash; the three kinds with no matched region to describe —
|
|
48
|
+
an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
|
|
49
|
+
the destination hostname instead. Never the secret, never the path or command that matched.
|
|
50
|
+
`dsh-dlp report` reads it back.
|
|
37
51
|
|
|
38
52
|
## What this is not
|
|
39
53
|
|
|
@@ -59,7 +73,9 @@ Three limits worth knowing before you rely on it:
|
|
|
59
73
|
control.**
|
|
60
74
|
- **Detection is pattern-based.** No entropy rule (measured, not assumed: the lowest
|
|
61
75
|
false-positive-free threshold cannot flag anything shorter than 64–66 characters). Encoded
|
|
62
|
-
forms pass. A homoglyph defeats every rule in this package.
|
|
76
|
+
forms pass. A homoglyph defeats every rule in this package. The card rule is Luhn-validated
|
|
77
|
+
and range-checked and found nothing across 272,635 lines of real source and docs, but
|
|
78
|
+
a *uniformly random* 16-digit number trips it 2.7% of the time and Maestro is not covered.
|
|
63
79
|
|
|
64
80
|
[The full list of limits →](https://charlotten7.github.io/dsh-dlp/)
|
|
65
81
|
|
|
@@ -95,9 +111,12 @@ load.
|
|
|
95
111
|
auditLog: /var/log/dsh-dlp.audit.jsonl
|
|
96
112
|
redactionKeyFile: /var/lib/dsh/dsh-dlp.redaction-key
|
|
97
113
|
policyFile: ./.dsh-dlp.yml # optional, lowest trust
|
|
114
|
+
aggressiveness: medium # low | medium | high
|
|
98
115
|
breadthTier: true
|
|
99
116
|
resultRedaction: true
|
|
100
117
|
telemetryRedaction: true
|
|
118
|
+
stepContextRedaction: true
|
|
119
|
+
claimedInputRedaction: true
|
|
101
120
|
configWriteAsk: true
|
|
102
121
|
approvalSuppressionAsk: true
|
|
103
122
|
```
|
|
@@ -106,10 +125,24 @@ load.
|
|
|
106
125
|
version control — it is what makes a placeholder's hash keyed rather than a bare digest anyone
|
|
107
126
|
holding a candidate secret could confirm.
|
|
108
127
|
|
|
128
|
+
**`aggressiveness` is one word for how far redaction reaches.** `low` guarantees nothing and lets
|
|
129
|
+
each toggle stand alone; `medium` — the default — guarantees every pass is on and that no toggle
|
|
130
|
+
can take one away; `high` adds the user's own typed prompt to what is redacted. It composes with
|
|
131
|
+
the toggles rather than overriding them: at `medium` and `high` a toggle set to `false`
|
|
132
|
+
contradicts the level and **fails the mount** with the fix in the message, rather than one setting
|
|
133
|
+
quietly beating the other.
|
|
134
|
+
|
|
135
|
+
> **Upgrading from 0.9.0.** The default `medium` matches the shipped toggle defaults exactly, so
|
|
136
|
+
> an install that never wrote a toggle is unchanged. An install that set any redaction toggle to
|
|
137
|
+
> `false` now refuses to mount; add `aggressiveness: low` to the same row and it means what it
|
|
138
|
+
> meant before. `high` is opt-in.
|
|
139
|
+
|
|
109
140
|
**The guard floor has no configuration.** Credential-path denial and secret-argument denial are
|
|
110
141
|
security invariants, not deployment-varying tunables. A repo-local `policyFile` is the lowest
|
|
111
142
|
trust rank and may only *tighten*: add deny patterns, add egress tool names, raise a severity,
|
|
112
|
-
switch a pass on.
|
|
143
|
+
switch a pass on. It cannot reach `aggressiveness` — raising the level would let a hostile
|
|
144
|
+
workspace put placeholders into the user's own prompt. Any downgrade makes the whole file
|
|
145
|
+
invalid.
|
|
113
146
|
|
|
114
147
|
[Configuration reference →](https://charlotten7.github.io/dsh-dlp/configuration.html) ·
|
|
115
148
|
[What gets denied →](https://charlotten7.github.io/dsh-dlp/denials.html) ·
|
package/cordis.patch.yml
CHANGED
|
@@ -15,10 +15,18 @@
|
|
|
15
15
|
config:
|
|
16
16
|
auditLog: !!js dshHomePath('dsh-dlp.audit.jsonl')
|
|
17
17
|
redactionKeyFile: !!js dshHomePath('dsh-dlp.redaction-key')
|
|
18
|
+
# `medium` guarantees every pass below is on and that none of them can
|
|
19
|
+
# be switched off; `high` adds the user's own typed prompt to what is
|
|
20
|
+
# redacted; `low` guarantees nothing and is what a deployment needing
|
|
21
|
+
# a pass off must set. A `false` below contradicting the level is a
|
|
22
|
+
# load-time error rather than a setting that quietly loses.
|
|
23
|
+
aggressiveness: medium
|
|
18
24
|
maxScanBytes: 1048576
|
|
19
25
|
breadthTier: true
|
|
20
26
|
resultRedaction: true
|
|
21
27
|
telemetryRedaction: true
|
|
28
|
+
stepContextRedaction: true
|
|
29
|
+
claimedInputRedaction: true
|
|
22
30
|
remoteImageNeutralization: true
|
|
23
31
|
redactTelemetryWorkspacePaths: true
|
|
24
32
|
configWriteAsk: true
|
package/lib/detectors.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Two detection tiers and the vocabulary they share.
|
|
3
3
|
*
|
|
4
|
-
* Tier 1 is a synchronous table of prefix-anchored
|
|
5
|
-
* because two of the three seams
|
|
4
|
+
* Tier 1 is a synchronous table of token formats — prefix-anchored but for the
|
|
5
|
+
* check-digit-validated card rule — owned here because two of the three seams
|
|
6
|
+
* this plugin uses are synchronous:
|
|
6
7
|
* `ToolGuard` returns `string | undefined` and the `session-telemetry/record`
|
|
7
8
|
* waterfall returns a record, neither of which can await. Tier 2 wraps
|
|
8
9
|
* `@secretlint/core`, which runs in-process with no subprocess but resolves a
|
|
@@ -31,16 +32,136 @@ export function severityRank(severity) {
|
|
|
31
32
|
}
|
|
32
33
|
/** Severity at or above which the guard floor denies rather than only redacting. */
|
|
33
34
|
export const DENY_SEVERITY = 'high';
|
|
35
|
+
/**
|
|
36
|
+
* Whether a digit string satisfies the Luhn check digit (ISO/IEC 7812-1).
|
|
37
|
+
* @param digits - the number with every separator already removed.
|
|
38
|
+
* @returns whether the trailing check digit is consistent with the rest.
|
|
39
|
+
*/
|
|
40
|
+
function luhnValid(digits) {
|
|
41
|
+
let sum = 0;
|
|
42
|
+
let double = false;
|
|
43
|
+
for (let index = digits.length - 1; index >= 0; index -= 1) {
|
|
44
|
+
let value = digits.charCodeAt(index) - 48;
|
|
45
|
+
if (double) {
|
|
46
|
+
value *= 2;
|
|
47
|
+
if (value > 9)
|
|
48
|
+
value -= 9;
|
|
49
|
+
}
|
|
50
|
+
sum += value;
|
|
51
|
+
double = !double;
|
|
52
|
+
}
|
|
53
|
+
return sum % 10 === 0;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Issuer identification ranges, each with the lengths that issuer assigns.
|
|
57
|
+
*
|
|
58
|
+
* The length is half of each rule. Luhn alone accepts one digit run in ten,
|
|
59
|
+
* and these ranges at these lengths accept 26.8% of the sixteen-digit space
|
|
60
|
+
* (measured over 200,000 numbers with the check digit forced valid), so it is
|
|
61
|
+
* the pair that separates a card number from an order id beginning with a 4.
|
|
62
|
+
*
|
|
63
|
+
* Maestro is deliberately absent. Its ranges run from `50` and `56`-`58`
|
|
64
|
+
* through a bare leading `6` at any length from 12 to 19, which is most of the
|
|
65
|
+
* six-prefixed numeric space at most of the lengths an identifier uses;
|
|
66
|
+
* including it would cost more ordinary text than it catches.
|
|
67
|
+
* `docs/redaction.md` records that gap rather than implying coverage.
|
|
68
|
+
*/
|
|
69
|
+
const CARD_RANGES = [
|
|
70
|
+
{ prefix: /^4/, lengths: [13, 16, 19] },
|
|
71
|
+
{ prefix: /^5[1-5]/, lengths: [16] },
|
|
72
|
+
// Mastercard's 2-series, 222100 through 272099.
|
|
73
|
+
{ prefix: /^2(?:22[1-9]|2[3-9]\d|[3-6]\d\d|7[01]\d|720)/, lengths: [16] },
|
|
74
|
+
{ prefix: /^3[47]/, lengths: [15] },
|
|
75
|
+
// Discover: 6011, 622126-622925, 644-649, 65.
|
|
76
|
+
{ prefix: /^6(?:011|5\d\d|4[4-9]\d|22(?:12[6-9]|1[3-9]\d|[2-8]\d\d|9[01]\d|92[0-5]))/, lengths: [16, 19] },
|
|
77
|
+
{ prefix: /^35(?:2[89]|[3-8]\d)/, lengths: [16, 17, 18, 19] },
|
|
78
|
+
// Diners Club: 300-305, 3095, 36, 38, 39.
|
|
79
|
+
{ prefix: /^3(?:0(?:[0-5]|95)|[689])/, lengths: [14, 16, 19] },
|
|
80
|
+
{ prefix: /^62/, lengths: [16, 17, 18, 19] },
|
|
81
|
+
];
|
|
82
|
+
/** Whether a bare digit string is a number some issuer would have handed out. */
|
|
83
|
+
function isIssuedCardNumber(digits) {
|
|
84
|
+
const issued = CARD_RANGES.some(range => range.lengths.includes(digits.length) && range.prefix.test(digits));
|
|
85
|
+
return issued && luhnValid(digits);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Shortest printed digit group.
|
|
89
|
+
*
|
|
90
|
+
* This is the constant that keeps an amount written with space thousands
|
|
91
|
+
* separators from reading as a card number. Measured over 200,000 random
|
|
92
|
+
* sixteen-digit amounts written `4 920 007 989 245 430`: admitting groups
|
|
93
|
+
* shorter than three flags 3.75% of them, requiring three flags 0.19%.
|
|
94
|
+
*/
|
|
95
|
+
const MIN_CARD_GROUP = 3;
|
|
96
|
+
/** Longest printed digit group: American Express is printed 4-6-5. */
|
|
97
|
+
const MAX_CARD_GROUP = 6;
|
|
98
|
+
/** Most groups one match may span — a 19-digit number takes five, leaving room for one neighbour. */
|
|
99
|
+
const MAX_CARD_GROUPS = 6;
|
|
100
|
+
/** One printed group of a grouped card number. */
|
|
101
|
+
const CARD_GROUP = String.raw `\d{${MIN_CARD_GROUP},${MAX_CARD_GROUP}}`;
|
|
102
|
+
/**
|
|
103
|
+
* A digit run that could be a card number: either unbroken, or printed in
|
|
104
|
+
* groups separated by single spaces or hyphens.
|
|
105
|
+
*
|
|
106
|
+
* The two lookarounds drop the commonest long digit run in machine output that
|
|
107
|
+
* is not an identifier at all — the fractional part of a decimal. A JavaScript
|
|
108
|
+
* `Math.random()` prints sixteen digits after the point, and 1.63% of them get
|
|
109
|
+
* past both the issuer ranges and the check digit; measured over 200,000
|
|
110
|
+
* printouts, the lookbehind takes that to zero.
|
|
111
|
+
*/
|
|
112
|
+
const CARD_CANDIDATE = new RegExp(String.raw `(?<!\d\.)\b(?:\d{13,19}|${CARD_GROUP}(?:[ -]${CARD_GROUP}){1,${MAX_CARD_GROUPS - 1}})\b(?!\.\d)`, 'g');
|
|
113
|
+
/**
|
|
114
|
+
* Narrow one candidate digit run to the card number inside it.
|
|
115
|
+
*
|
|
116
|
+
* The pattern is greedy across printed groups, so a run holding a card number
|
|
117
|
+
* beside an expiry date, an amount or a year arrives here as one match. Every
|
|
118
|
+
* group-aligned window of the run is tested, longest first, and each window's
|
|
119
|
+
* edges sit on a separator or on the ends of the match — where the pattern
|
|
120
|
+
* already established a word boundary — so narrowing never reports part of a
|
|
121
|
+
* longer unbroken number. An unbroken run has exactly one window, itself,
|
|
122
|
+
* which is what keeps a 20-digit identifier from being reported as the
|
|
123
|
+
* 16-digit card number hiding in its first sixteen digits.
|
|
124
|
+
* @param match - the matched candidate.
|
|
125
|
+
* @returns offsets of the card number within the match, or `undefined` when there is none.
|
|
126
|
+
*/
|
|
127
|
+
export function refinePaymentCardNumber(match) {
|
|
128
|
+
const groups = [];
|
|
129
|
+
let cursor = 0;
|
|
130
|
+
for (const digits of match.split(/[ -]/)) {
|
|
131
|
+
groups.push({ start: cursor, end: cursor + digits.length, digits });
|
|
132
|
+
cursor += digits.length + 1;
|
|
133
|
+
}
|
|
134
|
+
const windows = [];
|
|
135
|
+
for (const [index, first] of groups.entries()) {
|
|
136
|
+
let digits = '';
|
|
137
|
+
for (const group of groups.slice(index)) {
|
|
138
|
+
digits += group.digits;
|
|
139
|
+
windows.push({ start: first.start, end: group.end, digits });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Longest first, so a number is reported whole rather than as a prefix of
|
|
143
|
+
// itself that happens to validate at a shorter issuer length.
|
|
144
|
+
windows.sort((left, right) => (right.end - right.start) - (left.end - left.start));
|
|
145
|
+
for (const window of windows) {
|
|
146
|
+
if (isIssuedCardNumber(window.digits))
|
|
147
|
+
return { start: window.start, end: window.end };
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
34
151
|
/**
|
|
35
152
|
* Tier 1's rule table. Deliberately narrow: only formats whose prefix or
|
|
36
153
|
* delimiters make a match structurally unambiguous, plus PEM blocks and
|
|
37
154
|
* credential-bearing URLs. Anything requiring entropy heuristics is left to
|
|
38
155
|
* tier 2, where a false positive costs a redaction rather than a denial.
|
|
39
156
|
*
|
|
40
|
-
*
|
|
41
|
-
* keeps growing rather than deferring to tier 2: the
|
|
42
|
-
* waterfall is synchronous and cannot reach tier 2
|
|
43
|
-
* here is exported in the clear when telemetry is
|
|
157
|
+
* A structurally unambiguous match is the membership criterion, and the reason
|
|
158
|
+
* this table keeps growing rather than deferring to tier 2: the
|
|
159
|
+
* `session-telemetry/record` waterfall is synchronous and cannot reach tier 2
|
|
160
|
+
* at all, so a format missing here is exported in the clear when telemetry is
|
|
161
|
+
* on. For every rule but one that means a prefix. The exception is
|
|
162
|
+
* `dsh-dlp/payment-card-number`, which has no prefix to anchor on and earns
|
|
163
|
+
* its place through an issuer range, a length that issuer assigns and a check
|
|
164
|
+
* digit instead.
|
|
44
165
|
*/
|
|
45
166
|
export const SYNC_RULES = [
|
|
46
167
|
{ id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
|
|
@@ -126,6 +247,23 @@ export const SYNC_RULES = [
|
|
|
126
247
|
{ id: 'dsh-dlp/discord-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/(?:\w+\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]{10,}/g },
|
|
127
248
|
{ id: 'dsh-dlp/teams-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/[A-Za-z0-9.-]*webhook\.office\.com\/webhookb2\/[A-Za-z0-9@/_-]{10,}/g },
|
|
128
249
|
{ id: 'dsh-dlp/secret-assignment', version: 1, severity: 'medium', pattern: /\b(?:api[_-]?key|secret[_-]?key|client[_-]?secret|password|passwd|access[_-]?token|auth[_-]?token)\b\s*[=:]\s*["']?[A-Za-z0-9/+=_-]{16,}["']?/gi },
|
|
250
|
+
// Cardholder data, which is the one class here that is not a credential the
|
|
251
|
+
// agent could have fetched: it is typed, pasted or read out of a customer
|
|
252
|
+
// record, and it is regulated wherever it goes. `medium` is deliberate and
|
|
253
|
+
// is the only rule in this table below `high` other than the assignment
|
|
254
|
+
// heuristic above it. The guard floor denies at `high`, and a denial from
|
|
255
|
+
// this rule would be unoverridable, while its false positives are ordinary
|
|
256
|
+
// long numbers rather than malformed credentials. A deployment that wants
|
|
257
|
+
// the denial raises the severity from its repo-local policy, which the
|
|
258
|
+
// tighten-only tier already allows.
|
|
259
|
+
{
|
|
260
|
+
id: 'dsh-dlp/payment-card-number',
|
|
261
|
+
version: 1,
|
|
262
|
+
severity: 'medium',
|
|
263
|
+
pattern: CARD_CANDIDATE,
|
|
264
|
+
refine: refinePaymentCardNumber,
|
|
265
|
+
exact: true,
|
|
266
|
+
},
|
|
129
267
|
];
|
|
130
268
|
/**
|
|
131
269
|
* Build one class's run pattern from its ranges, so the two cannot drift apart.
|
|
@@ -318,13 +456,18 @@ export function scanSync(text, rules = SYNC_RULES) {
|
|
|
318
456
|
for (const rule of rules) {
|
|
319
457
|
for (const match of text.matchAll(rule.pattern)) {
|
|
320
458
|
// `matchAll` on a global pattern always reports an index.
|
|
321
|
-
const
|
|
459
|
+
const region = rule.refine === undefined
|
|
460
|
+
? { start: 0, end: match[0].length }
|
|
461
|
+
: rule.refine(match[0]);
|
|
462
|
+
if (region === undefined)
|
|
463
|
+
continue;
|
|
322
464
|
detections.push({
|
|
323
465
|
ruleId: rule.id,
|
|
324
466
|
ruleVersion: rule.version,
|
|
325
467
|
severity: rule.severity,
|
|
326
|
-
start,
|
|
327
|
-
end:
|
|
468
|
+
start: match.index + region.start,
|
|
469
|
+
end: match.index + region.end,
|
|
470
|
+
...rule.exact === true ? { exact: true } : {},
|
|
328
471
|
});
|
|
329
472
|
}
|
|
330
473
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `dsh-dlp` — data-loss prevention for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Seven registrations, in descending order of how much they can be trusted:
|
|
5
5
|
*
|
|
6
6
|
* 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
|
|
7
7
|
* credential paths named in a path-typed argument and for secrets heading
|
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
20
20
|
* Prepended, so it redacts what the rest of the waterfall returned; a
|
|
21
21
|
* listener registering later with the same option still runs ahead of it.
|
|
22
|
+
* 3b. `agent/pre-step` — redaction of the messages one step enters with: the
|
|
23
|
+
* context a listener splices in (the workspace instruction chain, the
|
|
24
|
+
* session skill catalog, a captured terminal pane, a hook's
|
|
25
|
+
* `additionalContext`) and the input the loop claimed from the inbox that
|
|
26
|
+
* the user did not type (a `dsh-webhook` delivery, a subagent's settled
|
|
27
|
+
* result, an agent relay). The loop appends what this waterfall returns as
|
|
28
|
+
* the `user/message` surface events every request is derived from, so the
|
|
29
|
+
* model-visible durable copy is the redacted one. A claimed message's
|
|
30
|
+
* earlier `agent/inbox/spliced` delivery record keeps the original; it
|
|
31
|
+
* derives no model message, and ADR §30 says why that is the wanted
|
|
32
|
+
* asymmetry. `source.kind: 'user'` is exempt.
|
|
22
33
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
23
34
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
24
35
|
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
|
@@ -47,6 +58,7 @@ import { evaluateConfigWrite } from "./config-writes.js";
|
|
|
47
58
|
import { evaluateApprovalSuppression } from "./approvals.js";
|
|
48
59
|
import { approvalSeamNotice, askReach } from "./approval-reach.js";
|
|
49
60
|
import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
|
|
61
|
+
import { redactStepContext } from "./steps.js";
|
|
50
62
|
import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
|
|
51
63
|
import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
|
|
52
64
|
export { Config } from "./policy.js";
|
|
@@ -364,6 +376,43 @@ export function apply(ctx, config) {
|
|
|
364
376
|
return redacted.decision;
|
|
365
377
|
}, { prepend: true });
|
|
366
378
|
}
|
|
379
|
+
if (policy.stepContextRedaction || policy.claimedInputRedaction) {
|
|
380
|
+
// One listener for both toggles: they cover two classes of message
|
|
381
|
+
// entering the same waterfall, and scanning them in one pass is what finds
|
|
382
|
+
// a secret split across the boundary between them. `redactStepContext`
|
|
383
|
+
// reads both flags and leaves an out-of-scope message as the object it
|
|
384
|
+
// arrived as.
|
|
385
|
+
//
|
|
386
|
+
// Prepended for the reason the result and telemetry seams are: listeners
|
|
387
|
+
// run outermost-first, so registering ahead of the chain is what lets this
|
|
388
|
+
// one redact the messages the rest of the waterfall spliced in rather than
|
|
389
|
+
// have its own replacement discarded afterwards.
|
|
390
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
391
|
+
const redacted = await redactStepContext(await next(), payload.messages, policy, hasher);
|
|
392
|
+
const indicators = Object.keys(redacted.indicators).length > 0
|
|
393
|
+
? { unicode: redacted.indicators }
|
|
394
|
+
: {};
|
|
395
|
+
if (redacted.spans.length > 0 || redacted.truncatedScan || Object.keys(indicators).length > 0) {
|
|
396
|
+
sink.write({
|
|
397
|
+
v: RECORD_VERSION,
|
|
398
|
+
time: new Date().toISOString(),
|
|
399
|
+
kind: 'step-context-redaction',
|
|
400
|
+
decisionId: newDecisionId(),
|
|
401
|
+
sessionId: String(payload.agent.session.id),
|
|
402
|
+
turn: payload.turn,
|
|
403
|
+
step: payload.step,
|
|
404
|
+
spans: redacted.spans,
|
|
405
|
+
...redacted.truncatedScan ? { truncatedScan: true } : {},
|
|
406
|
+
...indicators,
|
|
407
|
+
// Only when the pass covered claimed input: an operator counting
|
|
408
|
+
// deliveries must not have to read an empty list as "none arrived"
|
|
409
|
+
// on every workspace-instruction record.
|
|
410
|
+
...redacted.claimedSources.length > 0 ? { claimedSources: redacted.claimedSources } : {},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return redacted.decision;
|
|
414
|
+
}, { prepend: true });
|
|
415
|
+
}
|
|
367
416
|
if (policy.remoteImageNeutralization) {
|
|
368
417
|
ctx.on('llm/stream', (options, next) => neutralizeImageStream(next(), (host) => {
|
|
369
418
|
sink.write({
|
|
@@ -397,11 +446,15 @@ export function apply(ctx, config) {
|
|
|
397
446
|
});
|
|
398
447
|
}
|
|
399
448
|
return redacted.record;
|
|
400
|
-
// Prepended for the reason the other
|
|
401
|
-
//
|
|
449
|
+
// Prepended for the reason the other seams are: a listener that returns
|
|
450
|
+
// without calling `next()` vetoes every listener behind it for that
|
|
451
|
+
// dispatch — `Events.dispatch()` hands the waterfall a fresh array, so
|
|
452
|
+
// the registration survives and runs again on the next dispatch — and
|
|
402
453
|
// this one is the only thing standing between an exported telemetry
|
|
403
|
-
// record and the wire.
|
|
404
|
-
//
|
|
454
|
+
// record and the wire. One vetoed dispatch is one record exported in the
|
|
455
|
+
// clear, which is why position matters here. Best-effort, as at the
|
|
456
|
+
// mutation snapshot: another plugin registering later with the same
|
|
457
|
+
// option lands ahead again.
|
|
405
458
|
}, { prepend: true });
|
|
406
459
|
}
|
|
407
460
|
}
|
package/lib/policy.js
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* Rank 3 is a file inside the workspace, so a hostile repository ships one and
|
|
12
12
|
* a prompt-injected agent can write one. It may add deny patterns, add egress
|
|
13
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.
|
|
14
|
+
* and every downgrade, is a load-time error rather than a silent ignore. It
|
|
15
|
+
* cannot reach {@link Config.aggressiveness}: the level's own lever is
|
|
16
|
+
* redacting what the user typed, and a workspace that could force that could
|
|
17
|
+
* garble the user's own words on their way to the model.
|
|
15
18
|
* @module dsh-dlp/policy
|
|
16
19
|
*/
|
|
17
20
|
import { readFileSync } from 'node:fs';
|
|
@@ -23,6 +26,7 @@ import { SYNC_RULES, severityRank, stripControlSequences } from "./detectors.js"
|
|
|
23
26
|
import { resolveDshHome } from "./home.js";
|
|
24
27
|
import { CREDENTIAL_PATH_RULES, escapePathPattern, homeCredentialPathRules } from "./paths.js";
|
|
25
28
|
export const Config = z.object({
|
|
29
|
+
aggressiveness: z.union([z.const('low'), z.const('medium'), z.const('high')]).default('medium'),
|
|
26
30
|
auditLog: z.string().required(),
|
|
27
31
|
redactionKeyFile: z.string().required(),
|
|
28
32
|
policyFile: z.string(),
|
|
@@ -30,6 +34,8 @@ export const Config = z.object({
|
|
|
30
34
|
breadthTier: z.boolean().default(true),
|
|
31
35
|
resultRedaction: z.boolean().default(true),
|
|
32
36
|
telemetryRedaction: z.boolean().default(true),
|
|
37
|
+
stepContextRedaction: z.boolean().default(true),
|
|
38
|
+
claimedInputRedaction: z.boolean().default(true),
|
|
33
39
|
remoteImageNeutralization: z.boolean().default(true),
|
|
34
40
|
redactTelemetryWorkspacePaths: z.boolean().default(true),
|
|
35
41
|
configWriteAsk: z.boolean().default(true),
|
|
@@ -40,11 +46,46 @@ const ENABLEABLE = [
|
|
|
40
46
|
'breadthTier',
|
|
41
47
|
'resultRedaction',
|
|
42
48
|
'telemetryRedaction',
|
|
49
|
+
'stepContextRedaction',
|
|
50
|
+
'claimedInputRedaction',
|
|
43
51
|
'remoteImageNeutralization',
|
|
44
52
|
'redactTelemetryWorkspacePaths',
|
|
45
53
|
'configWriteAsk',
|
|
46
54
|
'approvalSuppressionAsk',
|
|
47
55
|
];
|
|
56
|
+
/**
|
|
57
|
+
* Passes each level requires, whatever the toggles say.
|
|
58
|
+
*
|
|
59
|
+
* `low` requires none, which is what makes it the level a deployment moves to
|
|
60
|
+
* when it needs a pass off — and what makes every configuration that was legal
|
|
61
|
+
* before this field existed still legal. `medium` and `high` require all of
|
|
62
|
+
* them, so the one-word setting and the nine booleans can never describe
|
|
63
|
+
* different plugins.
|
|
64
|
+
*/
|
|
65
|
+
const LEVEL_REQUIRES = {
|
|
66
|
+
low: [],
|
|
67
|
+
medium: ENABLEABLE,
|
|
68
|
+
high: ENABLEABLE,
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Reject a configuration whose level and whose toggles describe different
|
|
72
|
+
* behaviour.
|
|
73
|
+
*
|
|
74
|
+
* Loud at load rather than silently resolved either way: two
|
|
75
|
+
* deployment-controlled settings disagreeing is self-contained
|
|
76
|
+
* misconfiguration, and whichever of them lost would be a pass an operator
|
|
77
|
+
* believes is running and is not, or one they believe is off and is not.
|
|
78
|
+
* @param config - the deployment-controlled configuration.
|
|
79
|
+
* @throws PolicyError naming every toggle that contradicts the level.
|
|
80
|
+
*/
|
|
81
|
+
function assertLevelAgrees(config) {
|
|
82
|
+
const contradicted = LEVEL_REQUIRES[config.aggressiveness].filter(toggle => !config[toggle]);
|
|
83
|
+
if (contradicted.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
throw new PolicyError(`aggressiveness: ${config.aggressiveness} requires every pass this package ships, but`
|
|
86
|
+
+ ` ${contradicted.join(', ')} ${contradicted.length === 1 ? 'is' : 'are'} set to false.`
|
|
87
|
+
+ ' Set aggressiveness: low to choose passes individually, or drop the false setting.');
|
|
88
|
+
}
|
|
48
89
|
/** Keys a repo-local policy file may carry; anything else fails the load. */
|
|
49
90
|
const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
|
|
50
91
|
/** Payload version this package writes and accepts for repo-local policy files. */
|
|
@@ -266,6 +307,7 @@ function selfProtectionRules(config, dshHome) {
|
|
|
266
307
|
* @returns the effective policy every seam reads.
|
|
267
308
|
*/
|
|
268
309
|
export function resolvePolicy(config, repo) {
|
|
310
|
+
assertLevelAgrees(config);
|
|
269
311
|
const enabled = (toggle) => config[toggle] || (repo?.enable.includes(toggle) ?? false);
|
|
270
312
|
return {
|
|
271
313
|
credentialPathRules: [
|
|
@@ -280,9 +322,13 @@ export function resolvePolicy(config, repo) {
|
|
|
280
322
|
return raised === undefined ? rule : { ...rule, severity: raised };
|
|
281
323
|
}),
|
|
282
324
|
maxScanBytes: config.maxScanBytes,
|
|
325
|
+
aggressiveness: config.aggressiveness,
|
|
326
|
+
userTypedInputRedaction: config.aggressiveness === 'high',
|
|
283
327
|
breadthTier: enabled('breadthTier'),
|
|
284
328
|
resultRedaction: enabled('resultRedaction'),
|
|
285
329
|
telemetryRedaction: enabled('telemetryRedaction'),
|
|
330
|
+
stepContextRedaction: enabled('stepContextRedaction'),
|
|
331
|
+
claimedInputRedaction: enabled('claimedInputRedaction'),
|
|
286
332
|
remoteImageNeutralization: enabled('remoteImageNeutralization'),
|
|
287
333
|
redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
|
|
288
334
|
configWriteAsk: enabled('configWriteAsk'),
|
package/lib/results.js
CHANGED
|
@@ -44,7 +44,7 @@ const RENDER_SEPARATOR = '\n';
|
|
|
44
44
|
* @param policy - the effective policy.
|
|
45
45
|
* @returns a memoized lookup, the invisible-character counts, and whether tier 2 saw less than the whole rendering.
|
|
46
46
|
*/
|
|
47
|
-
async function prepareScan(strings, policy) {
|
|
47
|
+
export async function prepareScan(strings, policy) {
|
|
48
48
|
const rendered = strings.join(RENDER_SEPARATOR);
|
|
49
49
|
const { detections, truncated } = await scanAll(rendered, policy.syncRules, policy.maxScanBytes);
|
|
50
50
|
const memo = new Map();
|
|
@@ -87,7 +87,7 @@ function contentStrings(blocks) {
|
|
|
87
87
|
* @param messages - the contexts attached to a decision or ferried on a result.
|
|
88
88
|
* @returns every string those messages would put in front of the model or into the log.
|
|
89
89
|
*/
|
|
90
|
-
function messageStrings(messages) {
|
|
90
|
+
export function messageStrings(messages) {
|
|
91
91
|
return messages.flatMap(message => [...contentStrings(message.content), ...nestedStrings(message.source)]);
|
|
92
92
|
}
|
|
93
93
|
/**
|
package/lib/steps.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction of the messages one step enters with: the context a
|
|
3
|
+
* `agent/pre-step` listener splices in, and the input the loop claimed from
|
|
4
|
+
* the inbox that the user did not type.
|
|
5
|
+
*
|
|
6
|
+
* A tool result is not the only text that reaches the model. The agent loop
|
|
7
|
+
* dispatches `agent/pre-step` as a waterfall, appends every message the
|
|
8
|
+
* returned decision carries as a `user/message` surface event, and derives the
|
|
9
|
+
* next request from that surface. Two classes of text arrive that way.
|
|
10
|
+
*
|
|
11
|
+
* The first is what the waterfall itself adds, and nothing else scans it:
|
|
12
|
+
* `dsh-agent-instructions` splices the workspace `AGENTS.md`/`CLAUDE.md`
|
|
13
|
+
* chain, `dsh-tmux-context` splices captured pane text, the Claude Code and
|
|
14
|
+
* Codex hook bridges splice a hook command's `additionalContext`, and
|
|
15
|
+
* `dsh-tool-skill` splices a skill body a `/name` token asked for.
|
|
16
|
+
*
|
|
17
|
+
* The second is what the loop claimed from the inbox. Most of that is not the
|
|
18
|
+
* user typing either: `dsh-webhook` admits a verified third-party delivery's
|
|
19
|
+
* payload with `agent.followup()`, a subagent's settled result and an
|
|
20
|
+
* agent-to-agent relay arrive the same way, and so does anything
|
|
21
|
+
* `agent.inject()` seeded. {@link isUserTyped} is the exemption, and it is the
|
|
22
|
+
* only one, but it is not unconditional: it applies at `aggressiveness: low`
|
|
23
|
+
* and `medium`, and stops applying at `high`. This plugin cannot know where
|
|
24
|
+
* the request is going, and a card number a person typed on purpose is still
|
|
25
|
+
* cardholder data at whatever provider the deployment happens to be pointed
|
|
26
|
+
* at, so the deployment says which reading it wants rather than this module
|
|
27
|
+
* assuming one.
|
|
28
|
+
*
|
|
29
|
+
* A claimed message differs from an added one in exactly one way, and it is
|
|
30
|
+
* narrower than it looks. Its delivery was already recorded as
|
|
31
|
+
* `agent/inbox/spliced` before this waterfall ran, and no seam can rewrite a
|
|
32
|
+
* committed event. That event is not a surface event — `SurfaceEventType` is
|
|
33
|
+
* `user/message | assistant/message | tool/result` — so it derives no model
|
|
34
|
+
* message and no request is built from it. The copy the model reads is the
|
|
35
|
+
* `user/message` the loop appends after this waterfall, which is the copy this
|
|
36
|
+
* pass rewrites. "Model-visible ⟺ logged" therefore still holds; what the
|
|
37
|
+
* delivery record keeps is the original text of an untrusted delivery, which
|
|
38
|
+
* is evidence rather than a disagreement. ADR §30 states that asymmetry, and
|
|
39
|
+
* `tests/e2e/step-context.e2e.ts` pins it.
|
|
40
|
+
*
|
|
41
|
+
* Best-effort, like every seam here that is not `ctx.tools.guard()`: this
|
|
42
|
+
* listener registers with `{ prepend: true }` so it sees what the rest of the
|
|
43
|
+
* waterfall settled on, and a listener registering later with the same option
|
|
44
|
+
* lands ahead of it again.
|
|
45
|
+
* @module dsh-dlp/steps
|
|
46
|
+
*/
|
|
47
|
+
import { messageStrings, prepareScan } from "./results.js";
|
|
48
|
+
import { redactUserMessages } from "./redaction.js";
|
|
49
|
+
/**
|
|
50
|
+
* Whether one message the loop claimed from the inbox is the user's own
|
|
51
|
+
* typing, which exempts it below `aggressiveness: high`.
|
|
52
|
+
*
|
|
53
|
+
* `MessageSourceMap` is merge-extensible and every producer picks its own
|
|
54
|
+
* `kind`, so the exemption is a single allowed value rather than a list of
|
|
55
|
+
* denied ones: a source kind added by a package this plugin has never heard of
|
|
56
|
+
* is redacted rather than trusted. In the installed harness `kind: 'user'` is
|
|
57
|
+
* what the interactive entry points supply — `dsh-headless` for a CLI task,
|
|
58
|
+
* `dsh-acp` for an ACP prompt, `dsh-sdk-jsonrpc-server` for an SDK one, and
|
|
59
|
+
* `dsh-api-session-controller`'s `user-rpc` source for a browser prompt, which
|
|
60
|
+
* adds `rpcId` beside the same `kind`. `dsh-webhook`'s deliveries carry
|
|
61
|
+
* `kind: 'webhook'`, and the harness's own webhook invariant discriminates on
|
|
62
|
+
* exactly that value.
|
|
63
|
+
*
|
|
64
|
+
* Two producers borrow the value for text a person did not type: `dsh-subagent`
|
|
65
|
+
* and `dsh-subagent-in-process-driver` open a child agent with the parent's
|
|
66
|
+
* prompt under `kind: 'user'`. Those stay exempt here. Distinguishing them
|
|
67
|
+
* needs a fact the source does not carry, and inventing one would put a
|
|
68
|
+
* guessed value in a security decision.
|
|
69
|
+
*
|
|
70
|
+
* At `aggressiveness: high` the caller stops consulting this, so the two
|
|
71
|
+
* borrowed cases stop being exempt with everything else.
|
|
72
|
+
* @param message - one message the loop claimed from the inbox.
|
|
73
|
+
* @returns whether the message came from a person typing into their own prompt.
|
|
74
|
+
*/
|
|
75
|
+
export function isUserTyped(message) {
|
|
76
|
+
return message.source.kind === 'user';
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Redact the messages entering one step.
|
|
80
|
+
*
|
|
81
|
+
* @param decision - what the rest of the waterfall settled on.
|
|
82
|
+
* @param claimed - the messages the loop claimed from the inbox.
|
|
83
|
+
* @param policy - the effective policy after the tighten-only merge.
|
|
84
|
+
* @param hasher - mints each span's keyed hash.
|
|
85
|
+
* @returns the decision to return, plus what the pass found.
|
|
86
|
+
*/
|
|
87
|
+
export async function redactStepContext(decision, claimed, policy, hasher) {
|
|
88
|
+
const nothing = { spans: [], truncatedScan: false, indicators: {}, claimedSources: [] };
|
|
89
|
+
if (decision.kind !== 'enter')
|
|
90
|
+
return { decision, ...nothing };
|
|
91
|
+
// Object identity, which is what the loop and the shipped providers use: a
|
|
92
|
+
// provider splices its own message into the array it was handed, so a
|
|
93
|
+
// claimed message is the same object it arrived as.
|
|
94
|
+
const claimedSet = new Set(claimed);
|
|
95
|
+
const inScope = (message) => claimedSet.has(message)
|
|
96
|
+
? policy.claimedInputRedaction && (policy.userTypedInputRedaction || !isUserTyped(message))
|
|
97
|
+
: policy.stepContextRedaction;
|
|
98
|
+
const selected = decision.messages.filter(inScope);
|
|
99
|
+
if (selected.length === 0)
|
|
100
|
+
return { decision, ...nothing };
|
|
101
|
+
const claimedSources = [...new Set(selected.filter(message => claimedSet.has(message)).map(message => message.source.kind))];
|
|
102
|
+
const prepared = await prepareScan(messageStrings(selected), policy);
|
|
103
|
+
const redacted = redactUserMessages(selected, prepared.scan, hasher, '/messages');
|
|
104
|
+
const base = {
|
|
105
|
+
spans: redacted.spans,
|
|
106
|
+
truncatedScan: prepared.truncated,
|
|
107
|
+
indicators: prepared.indicators,
|
|
108
|
+
claimedSources,
|
|
109
|
+
};
|
|
110
|
+
if (!redacted.changed)
|
|
111
|
+
return { decision, ...base };
|
|
112
|
+
// Rebuilt position by position so every message keeps the place it had:
|
|
113
|
+
// ordering is what decides whether a workspace instruction reads before or
|
|
114
|
+
// after the user's request.
|
|
115
|
+
const replacements = [...redacted.messages];
|
|
116
|
+
const messages = decision.messages.map((message) => {
|
|
117
|
+
if (!inScope(message))
|
|
118
|
+
return message;
|
|
119
|
+
const replacement = replacements.shift();
|
|
120
|
+
/* v8 ignore next -- `redactUserMessages` returns one message per input, so this never runs short. */
|
|
121
|
+
return replacement ?? message;
|
|
122
|
+
});
|
|
123
|
+
return { decision: { ...decision, messages }, ...base };
|
|
124
|
+
}
|
package/lib/types/detectors.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Two detection tiers and the vocabulary they share.
|
|
3
3
|
*
|
|
4
|
-
* Tier 1 is a synchronous table of prefix-anchored
|
|
5
|
-
* because two of the three seams
|
|
4
|
+
* Tier 1 is a synchronous table of token formats — prefix-anchored but for the
|
|
5
|
+
* check-digit-validated card rule — owned here because two of the three seams
|
|
6
|
+
* this plugin uses are synchronous:
|
|
6
7
|
* `ToolGuard` returns `string | undefined` and the `session-telemetry/record`
|
|
7
8
|
* waterfall returns a record, neither of which can await. Tier 2 wraps
|
|
8
9
|
* `@secretlint/core`, which runs in-process with no subprocess but resolves a
|
|
@@ -39,8 +40,10 @@ export interface Detection {
|
|
|
39
40
|
readonly end: number;
|
|
40
41
|
/**
|
|
41
42
|
* Set when the offsets cover exactly what must be replaced, so redaction
|
|
42
|
-
* must not widen them to the surrounding delimiters.
|
|
43
|
-
*
|
|
43
|
+
* must not widen them to the surrounding delimiters. Two kinds of rule set
|
|
44
|
+
* it: the Unicode indicators, whose matches are the characters themselves,
|
|
45
|
+
* and the payment-card rule, whose match is validated digit by digit and so
|
|
46
|
+
* covers precisely the number. Everything else leaves it unset, because a
|
|
44
47
|
* secret's reported span is advisory and verified to under-cover (ADR §4).
|
|
45
48
|
*/
|
|
46
49
|
readonly exact?: true;
|
|
@@ -58,17 +61,55 @@ export interface SyncRule {
|
|
|
58
61
|
readonly severity: Severity;
|
|
59
62
|
/** Global-flagged; matched through `matchAll`, which never mutates this instance's `lastIndex`. */
|
|
60
63
|
readonly pattern: RegExp;
|
|
64
|
+
/**
|
|
65
|
+
* Narrows one raw match to the region that is really a detection, or rejects
|
|
66
|
+
* it outright by returning `undefined`. Offsets are relative to the match.
|
|
67
|
+
*
|
|
68
|
+
* Present for the one format a regular expression cannot decide on its own:
|
|
69
|
+
* a payment card number is a digit run that must also carry a known issuer
|
|
70
|
+
* prefix at a length that issuer assigns and satisfy a check digit, and the
|
|
71
|
+
* run it sits in may carry a neighbouring number the pattern swept up.
|
|
72
|
+
* Absent everywhere else, where the prefix makes the match unambiguous.
|
|
73
|
+
*/
|
|
74
|
+
readonly refine?: (match: string) => {
|
|
75
|
+
readonly start: number;
|
|
76
|
+
readonly end: number;
|
|
77
|
+
} | undefined;
|
|
78
|
+
/** Whether the reported offsets cover exactly the text to replace; see {@link Detection.exact}. */
|
|
79
|
+
readonly exact?: true;
|
|
61
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Narrow one candidate digit run to the card number inside it.
|
|
83
|
+
*
|
|
84
|
+
* The pattern is greedy across printed groups, so a run holding a card number
|
|
85
|
+
* beside an expiry date, an amount or a year arrives here as one match. Every
|
|
86
|
+
* group-aligned window of the run is tested, longest first, and each window's
|
|
87
|
+
* edges sit on a separator or on the ends of the match — where the pattern
|
|
88
|
+
* already established a word boundary — so narrowing never reports part of a
|
|
89
|
+
* longer unbroken number. An unbroken run has exactly one window, itself,
|
|
90
|
+
* which is what keeps a 20-digit identifier from being reported as the
|
|
91
|
+
* 16-digit card number hiding in its first sixteen digits.
|
|
92
|
+
* @param match - the matched candidate.
|
|
93
|
+
* @returns offsets of the card number within the match, or `undefined` when there is none.
|
|
94
|
+
*/
|
|
95
|
+
export declare function refinePaymentCardNumber(match: string): {
|
|
96
|
+
start: number;
|
|
97
|
+
end: number;
|
|
98
|
+
} | undefined;
|
|
62
99
|
/**
|
|
63
100
|
* Tier 1's rule table. Deliberately narrow: only formats whose prefix or
|
|
64
101
|
* delimiters make a match structurally unambiguous, plus PEM blocks and
|
|
65
102
|
* credential-bearing URLs. Anything requiring entropy heuristics is left to
|
|
66
103
|
* tier 2, where a false positive costs a redaction rather than a denial.
|
|
67
104
|
*
|
|
68
|
-
*
|
|
69
|
-
* keeps growing rather than deferring to tier 2: the
|
|
70
|
-
* waterfall is synchronous and cannot reach tier 2
|
|
71
|
-
* here is exported in the clear when telemetry is
|
|
105
|
+
* A structurally unambiguous match is the membership criterion, and the reason
|
|
106
|
+
* this table keeps growing rather than deferring to tier 2: the
|
|
107
|
+
* `session-telemetry/record` waterfall is synchronous and cannot reach tier 2
|
|
108
|
+
* at all, so a format missing here is exported in the clear when telemetry is
|
|
109
|
+
* on. For every rule but one that means a prefix. The exception is
|
|
110
|
+
* `dsh-dlp/payment-card-number`, which has no prefix to anchor on and earns
|
|
111
|
+
* its place through an issuer range, a length that issuer assigns and a check
|
|
112
|
+
* digit instead.
|
|
72
113
|
*/
|
|
73
114
|
export declare const SYNC_RULES: readonly SyncRule[];
|
|
74
115
|
/**
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `dsh-dlp` — data-loss prevention for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Seven registrations, in descending order of how much they can be trusted:
|
|
5
5
|
*
|
|
6
6
|
* 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
|
|
7
7
|
* credential paths named in a path-typed argument and for secrets heading
|
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
20
20
|
* Prepended, so it redacts what the rest of the waterfall returned; a
|
|
21
21
|
* listener registering later with the same option still runs ahead of it.
|
|
22
|
+
* 3b. `agent/pre-step` — redaction of the messages one step enters with: the
|
|
23
|
+
* context a listener splices in (the workspace instruction chain, the
|
|
24
|
+
* session skill catalog, a captured terminal pane, a hook's
|
|
25
|
+
* `additionalContext`) and the input the loop claimed from the inbox that
|
|
26
|
+
* the user did not type (a `dsh-webhook` delivery, a subagent's settled
|
|
27
|
+
* result, an agent relay). The loop appends what this waterfall returns as
|
|
28
|
+
* the `user/message` surface events every request is derived from, so the
|
|
29
|
+
* model-visible durable copy is the redacted one. A claimed message's
|
|
30
|
+
* earlier `agent/inbox/spliced` delivery record keeps the original; it
|
|
31
|
+
* derives no model message, and ADR §30 says why that is the wanted
|
|
32
|
+
* asymmetry. `source.kind: 'user'` is exempt.
|
|
22
33
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
23
34
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
24
35
|
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
package/lib/types/policy.d.ts
CHANGED
|
@@ -11,14 +11,41 @@
|
|
|
11
11
|
* Rank 3 is a file inside the workspace, so a hostile repository ships one and
|
|
12
12
|
* a prompt-injected agent can write one. It may add deny patterns, add egress
|
|
13
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.
|
|
14
|
+
* and every downgrade, is a load-time error rather than a silent ignore. It
|
|
15
|
+
* cannot reach {@link Config.aggressiveness}: the level's own lever is
|
|
16
|
+
* redacting what the user typed, and a workspace that could force that could
|
|
17
|
+
* garble the user's own words on their way to the model.
|
|
15
18
|
* @module dsh-dlp/policy
|
|
16
19
|
*/
|
|
17
20
|
import z from '@deepseek-ai/schemastery';
|
|
18
21
|
import { type Severity, type SyncRule } from './detectors.ts';
|
|
19
22
|
import { type CredentialPathRule } from './paths.ts';
|
|
23
|
+
/**
|
|
24
|
+
* How far redaction reaches, as one word.
|
|
25
|
+
*
|
|
26
|
+
* The level is not a tenth switch beside the nine below it. It states what the
|
|
27
|
+
* deployment guarantees, the toggles state which passes carry it out, and the
|
|
28
|
+
* two may not disagree: at `medium` and `high` a toggle set to `false`
|
|
29
|
+
* contradicts the guarantee and is a load-time error rather than a setting
|
|
30
|
+
* that quietly loses.
|
|
31
|
+
*
|
|
32
|
+
* - `low` guarantees nothing. Every pass is exactly its own toggle, which is
|
|
33
|
+
* still `true` unless the deployment says otherwise. This is the level to
|
|
34
|
+
* pick when a pass has to be switched off.
|
|
35
|
+
* - `medium` guarantees that every pass this package ships is on and that no
|
|
36
|
+
* toggle can take one away. The user's own typing stays exempt.
|
|
37
|
+
* - `high` is `medium` plus the one thing no toggle can express: the exemption
|
|
38
|
+
* in `isUserTyped` stops applying, so a secret in the user's own prompt is
|
|
39
|
+
* redacted before the request is built from it.
|
|
40
|
+
*/
|
|
41
|
+
export type Aggressiveness = 'low' | 'medium' | 'high';
|
|
20
42
|
/** Deployment configuration, validated from `cordis.yml`. */
|
|
21
43
|
export interface Config {
|
|
44
|
+
/**
|
|
45
|
+
* How far redaction reaches. See {@link Aggressiveness}; `high` is the only
|
|
46
|
+
* value that puts the user's own typed prompt in scope.
|
|
47
|
+
*/
|
|
48
|
+
aggressiveness: Aggressiveness;
|
|
22
49
|
/** Absolute path of this plugin's own JSONL audit sink. Never the session log. */
|
|
23
50
|
auditLog: string;
|
|
24
51
|
/** Absolute path of the installation's redaction key; created with 32 random bytes if absent. */
|
|
@@ -33,6 +60,21 @@ export interface Config {
|
|
|
33
60
|
resultRedaction: boolean;
|
|
34
61
|
/** Whether `session-telemetry/record` redaction runs. */
|
|
35
62
|
telemetryRedaction: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Whether the context an `agent/pre-step` listener splices into a step —
|
|
65
|
+
* the workspace instruction chain, captured terminal panes, a hook's
|
|
66
|
+
* `additionalContext`, a skill body a `/name` token asked for — is redacted
|
|
67
|
+
* before the loop logs it and builds the next request from it.
|
|
68
|
+
*/
|
|
69
|
+
stepContextRedaction: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the messages the loop claimed from the inbox are redacted before
|
|
72
|
+
* the step logs them and builds its request from them, for every message the
|
|
73
|
+
* user did not type: a `dsh-webhook` delivery's third-party payload, a
|
|
74
|
+
* subagent's settled result, an agent-to-agent relay, anything
|
|
75
|
+
* `agent.inject()` seeded. A message whose `source.kind` is `user` is exempt.
|
|
76
|
+
*/
|
|
77
|
+
claimedInputRedaction: boolean;
|
|
36
78
|
/** Whether remote markdown image destinations are neutralised in assistant output. */
|
|
37
79
|
remoteImageNeutralization: boolean;
|
|
38
80
|
/** Whether telemetry's `session.cwd` attribute is replaced with a keyed hash. */
|
|
@@ -52,7 +94,7 @@ export interface Config {
|
|
|
52
94
|
}
|
|
53
95
|
export declare const Config: z<Config>;
|
|
54
96
|
/** Config toggles a repo-local policy may switch on, and never off. */
|
|
55
|
-
declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk", "approvalSuppressionAsk"];
|
|
97
|
+
declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "stepContextRedaction", "claimedInputRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk", "approvalSuppressionAsk"];
|
|
56
98
|
/** One toggle name a repo-local policy may name in `enable`. */
|
|
57
99
|
export type EnableableToggle = typeof ENABLEABLE[number];
|
|
58
100
|
/** Payload version this package writes and accepts for repo-local policy files. */
|
|
@@ -70,9 +112,21 @@ export interface ResolvedPolicy {
|
|
|
70
112
|
readonly extraEgressTools: ReadonlySet<string>;
|
|
71
113
|
readonly syncRules: readonly SyncRule[];
|
|
72
114
|
readonly maxScanBytes: number;
|
|
115
|
+
readonly aggressiveness: Aggressiveness;
|
|
116
|
+
/**
|
|
117
|
+
* Whether a message the user typed themselves is scanned like any other.
|
|
118
|
+
*
|
|
119
|
+
* Set by the level alone, at `high` only, and by nothing else: there is no
|
|
120
|
+
* toggle for it and a repo-local policy cannot reach it. `isUserTyped` still
|
|
121
|
+
* decides which messages this applies to; this decides whether that answer
|
|
122
|
+
* exempts them.
|
|
123
|
+
*/
|
|
124
|
+
readonly userTypedInputRedaction: boolean;
|
|
73
125
|
readonly breadthTier: boolean;
|
|
74
126
|
readonly resultRedaction: boolean;
|
|
75
127
|
readonly telemetryRedaction: boolean;
|
|
128
|
+
readonly stepContextRedaction: boolean;
|
|
129
|
+
readonly claimedInputRedaction: boolean;
|
|
76
130
|
readonly remoteImageNeutralization: boolean;
|
|
77
131
|
readonly redactTelemetryWorkspacePaths: boolean;
|
|
78
132
|
readonly configWriteAsk: boolean;
|
package/lib/types/results.d.ts
CHANGED
|
@@ -13,9 +13,48 @@
|
|
|
13
13
|
* whole rule set applies here and not in the guard.
|
|
14
14
|
* @module dsh-dlp/results
|
|
15
15
|
*/
|
|
16
|
+
import type { UserMessage } from '@deepseek-ai/dsh-llm';
|
|
16
17
|
import type { JsonSchemaNode, PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools';
|
|
18
|
+
import { type Detection } from './detectors.ts';
|
|
17
19
|
import type { ResolvedPolicy } from './policy.ts';
|
|
18
20
|
import { type RedactedSpan, type SpanHasher } from './redaction.ts';
|
|
21
|
+
/** A scan function over already-scanned strings, plus how complete the scan was. */
|
|
22
|
+
export interface PreparedScan {
|
|
23
|
+
readonly scan: (text: string) => readonly Detection[];
|
|
24
|
+
readonly truncated: boolean;
|
|
25
|
+
/** Runs of each invisible-character class in the whole rendering, by rule id. */
|
|
26
|
+
readonly indicators: Readonly<Record<string, number>>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Scan a set of strings once and hand back a synchronous lookup, so the
|
|
30
|
+
* redaction walkers stay synchronous while detection stays async.
|
|
31
|
+
*
|
|
32
|
+
* The strings are scanned twice over: each on its own by tier 1, and all of
|
|
33
|
+
* them joined by both tiers. The joined pass is what finds a secret split
|
|
34
|
+
* across strings — a PEM block arriving as one `lines[i].text` per line, a
|
|
35
|
+
* token spanning two content blocks — which no per-string walk can see; its
|
|
36
|
+
* offsets are then mapped back onto the individual strings, because that is
|
|
37
|
+
* what the redaction walkers splice.
|
|
38
|
+
*
|
|
39
|
+
* Tier 2 runs once, over the joined text, and is budgeted by characters
|
|
40
|
+
* through `maxScanBytes`. One `lintSource` call per string would multiply a
|
|
41
|
+
* fixed per-call cost by however many pieces the tool happened to split its
|
|
42
|
+
* output into, and a budget counted in strings would make how much of a result
|
|
43
|
+
* is scanned depend on the same accident.
|
|
44
|
+
* @param strings - every string that will be redacted, in render order.
|
|
45
|
+
* @param policy - the effective policy.
|
|
46
|
+
* @returns a memoized lookup, the invisible-character counts, and whether tier 2 saw less than the whole rendering.
|
|
47
|
+
*/
|
|
48
|
+
export declare function prepareScan(strings: readonly string[], policy: ResolvedPolicy): Promise<PreparedScan>;
|
|
49
|
+
/**
|
|
50
|
+
* Text one set of `additionalContexts` carries: the model-facing blocks, plus
|
|
51
|
+
* whatever text the source records beside them — a `snapshot` source repeats
|
|
52
|
+
* the block text in `sections[].text`, a `notice` source its opening in
|
|
53
|
+
* `summary`, and a plugin's own source kind may carry more.
|
|
54
|
+
* @param messages - the contexts attached to a decision or ferried on a result.
|
|
55
|
+
* @returns every string those messages would put in front of the model or into the log.
|
|
56
|
+
*/
|
|
57
|
+
export declare function messageStrings(messages: readonly UserMessage[]): string[];
|
|
19
58
|
/** What a redaction pass produced, before an arm is chosen. */
|
|
20
59
|
export interface ResultRedaction {
|
|
21
60
|
readonly decision: PostToolDecision;
|
package/lib/types/sink.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare const RECORD_VERSION = 1;
|
|
|
38
38
|
*/
|
|
39
39
|
export declare const AUDIT_MODE = 416;
|
|
40
40
|
/** What produced one audit record. */
|
|
41
|
-
export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | 'pre-execute-ask-abstained' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
|
|
41
|
+
export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | 'pre-execute-ask-abstained' | 'execution-mutation' | 'result-redaction' | 'step-context-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
|
|
42
42
|
/** One durable record. Never carries matched secret text. */
|
|
43
43
|
export interface AuditRecord {
|
|
44
44
|
readonly v: number;
|
|
@@ -83,6 +83,14 @@ export interface AuditRecord {
|
|
|
83
83
|
* operator needs to change to get the prompt back.
|
|
84
84
|
*/
|
|
85
85
|
readonly askUnreachable?: AskUnreachable;
|
|
86
|
+
/**
|
|
87
|
+
* The distinct `source.kind` of the inbox-claimed messages a
|
|
88
|
+
* `step-context-redaction` pass covered, present only when it covered any.
|
|
89
|
+
* A kind names the producer that delivered the text — `webhook` for a
|
|
90
|
+
* `dsh-webhook` delivery — which is what separates a redacted third-party
|
|
91
|
+
* payload from a redacted workspace instruction file.
|
|
92
|
+
*/
|
|
93
|
+
readonly claimedSources?: readonly string[];
|
|
86
94
|
/** Telemetry record channel, for `telemetry-redaction`. */
|
|
87
95
|
readonly channel?: string;
|
|
88
96
|
/** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction of the messages one step enters with: the context a
|
|
3
|
+
* `agent/pre-step` listener splices in, and the input the loop claimed from
|
|
4
|
+
* the inbox that the user did not type.
|
|
5
|
+
*
|
|
6
|
+
* A tool result is not the only text that reaches the model. The agent loop
|
|
7
|
+
* dispatches `agent/pre-step` as a waterfall, appends every message the
|
|
8
|
+
* returned decision carries as a `user/message` surface event, and derives the
|
|
9
|
+
* next request from that surface. Two classes of text arrive that way.
|
|
10
|
+
*
|
|
11
|
+
* The first is what the waterfall itself adds, and nothing else scans it:
|
|
12
|
+
* `dsh-agent-instructions` splices the workspace `AGENTS.md`/`CLAUDE.md`
|
|
13
|
+
* chain, `dsh-tmux-context` splices captured pane text, the Claude Code and
|
|
14
|
+
* Codex hook bridges splice a hook command's `additionalContext`, and
|
|
15
|
+
* `dsh-tool-skill` splices a skill body a `/name` token asked for.
|
|
16
|
+
*
|
|
17
|
+
* The second is what the loop claimed from the inbox. Most of that is not the
|
|
18
|
+
* user typing either: `dsh-webhook` admits a verified third-party delivery's
|
|
19
|
+
* payload with `agent.followup()`, a subagent's settled result and an
|
|
20
|
+
* agent-to-agent relay arrive the same way, and so does anything
|
|
21
|
+
* `agent.inject()` seeded. {@link isUserTyped} is the exemption, and it is the
|
|
22
|
+
* only one, but it is not unconditional: it applies at `aggressiveness: low`
|
|
23
|
+
* and `medium`, and stops applying at `high`. This plugin cannot know where
|
|
24
|
+
* the request is going, and a card number a person typed on purpose is still
|
|
25
|
+
* cardholder data at whatever provider the deployment happens to be pointed
|
|
26
|
+
* at, so the deployment says which reading it wants rather than this module
|
|
27
|
+
* assuming one.
|
|
28
|
+
*
|
|
29
|
+
* A claimed message differs from an added one in exactly one way, and it is
|
|
30
|
+
* narrower than it looks. Its delivery was already recorded as
|
|
31
|
+
* `agent/inbox/spliced` before this waterfall ran, and no seam can rewrite a
|
|
32
|
+
* committed event. That event is not a surface event — `SurfaceEventType` is
|
|
33
|
+
* `user/message | assistant/message | tool/result` — so it derives no model
|
|
34
|
+
* message and no request is built from it. The copy the model reads is the
|
|
35
|
+
* `user/message` the loop appends after this waterfall, which is the copy this
|
|
36
|
+
* pass rewrites. "Model-visible ⟺ logged" therefore still holds; what the
|
|
37
|
+
* delivery record keeps is the original text of an untrusted delivery, which
|
|
38
|
+
* is evidence rather than a disagreement. ADR §30 states that asymmetry, and
|
|
39
|
+
* `tests/e2e/step-context.e2e.ts` pins it.
|
|
40
|
+
*
|
|
41
|
+
* Best-effort, like every seam here that is not `ctx.tools.guard()`: this
|
|
42
|
+
* listener registers with `{ prepend: true }` so it sees what the rest of the
|
|
43
|
+
* waterfall settled on, and a listener registering later with the same option
|
|
44
|
+
* lands ahead of it again.
|
|
45
|
+
* @module dsh-dlp/steps
|
|
46
|
+
*/
|
|
47
|
+
import type { PreStepDecision } from '@deepseek-ai/dsh-agent';
|
|
48
|
+
import type { UserMessage } from '@deepseek-ai/dsh-llm';
|
|
49
|
+
import type { ResolvedPolicy } from './policy.ts';
|
|
50
|
+
import { type RedactedSpan, type SpanHasher } from './redaction.ts';
|
|
51
|
+
/** What one pass over a step's entering messages produced. */
|
|
52
|
+
export interface StepRedaction {
|
|
53
|
+
/** The decision to return: the same object when nothing was replaced. */
|
|
54
|
+
readonly decision: PreStepDecision;
|
|
55
|
+
/** The regions replaced, described by rule identity, offsets and keyed hash only. */
|
|
56
|
+
readonly spans: readonly RedactedSpan[];
|
|
57
|
+
/** Set when the scanned text exceeded `maxScanBytes` and tier 2 saw less than all of it. */
|
|
58
|
+
readonly truncatedScan: boolean;
|
|
59
|
+
/** Runs of each invisible-character class the scanned messages carried, by rule id. */
|
|
60
|
+
readonly indicators: Readonly<Record<string, number>>;
|
|
61
|
+
/**
|
|
62
|
+
* The distinct `source.kind` of every inbox-claimed message this pass
|
|
63
|
+
* scanned, in first-seen order. Empty when the pass only covered messages
|
|
64
|
+
* the waterfall added. An operator reading the sink needs this to tell a
|
|
65
|
+
* redacted workspace instruction from a redacted third-party delivery.
|
|
66
|
+
*/
|
|
67
|
+
readonly claimedSources: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether one message the loop claimed from the inbox is the user's own
|
|
71
|
+
* typing, which exempts it below `aggressiveness: high`.
|
|
72
|
+
*
|
|
73
|
+
* `MessageSourceMap` is merge-extensible and every producer picks its own
|
|
74
|
+
* `kind`, so the exemption is a single allowed value rather than a list of
|
|
75
|
+
* denied ones: a source kind added by a package this plugin has never heard of
|
|
76
|
+
* is redacted rather than trusted. In the installed harness `kind: 'user'` is
|
|
77
|
+
* what the interactive entry points supply — `dsh-headless` for a CLI task,
|
|
78
|
+
* `dsh-acp` for an ACP prompt, `dsh-sdk-jsonrpc-server` for an SDK one, and
|
|
79
|
+
* `dsh-api-session-controller`'s `user-rpc` source for a browser prompt, which
|
|
80
|
+
* adds `rpcId` beside the same `kind`. `dsh-webhook`'s deliveries carry
|
|
81
|
+
* `kind: 'webhook'`, and the harness's own webhook invariant discriminates on
|
|
82
|
+
* exactly that value.
|
|
83
|
+
*
|
|
84
|
+
* Two producers borrow the value for text a person did not type: `dsh-subagent`
|
|
85
|
+
* and `dsh-subagent-in-process-driver` open a child agent with the parent's
|
|
86
|
+
* prompt under `kind: 'user'`. Those stay exempt here. Distinguishing them
|
|
87
|
+
* needs a fact the source does not carry, and inventing one would put a
|
|
88
|
+
* guessed value in a security decision.
|
|
89
|
+
*
|
|
90
|
+
* At `aggressiveness: high` the caller stops consulting this, so the two
|
|
91
|
+
* borrowed cases stop being exempt with everything else.
|
|
92
|
+
* @param message - one message the loop claimed from the inbox.
|
|
93
|
+
* @returns whether the message came from a person typing into their own prompt.
|
|
94
|
+
*/
|
|
95
|
+
export declare function isUserTyped(message: UserMessage): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Redact the messages entering one step.
|
|
98
|
+
*
|
|
99
|
+
* @param decision - what the rest of the waterfall settled on.
|
|
100
|
+
* @param claimed - the messages the loop claimed from the inbox.
|
|
101
|
+
* @param policy - the effective policy after the tighten-only merge.
|
|
102
|
+
* @param hasher - mints each span's keyed hash.
|
|
103
|
+
* @returns the decision to return, plus what the pass found.
|
|
104
|
+
*/
|
|
105
|
+
export declare function redactStepContext(decision: PreStepDecision, claimed: readonly UserMessage[], policy: ResolvedPolicy, hasher: SpanHasher): Promise<StepRedaction>;
|
|
106
|
+
//# sourceMappingURL=steps.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dlp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Data-loss-prevention plugin for DeepSeek Harness: a non-configurable tool guard floor, tool-result redaction, and fail-closed telemetry redaction",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ivan Tyshchenko <nsof@protonmail.com>",
|
|
@@ -51,10 +51,11 @@
|
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
54
|
-
"@deepseek-ai/dsh-
|
|
55
|
-
"@deepseek-ai/dsh-
|
|
56
|
-
"@deepseek-ai/dsh-session
|
|
57
|
-
"@deepseek-ai/dsh-
|
|
54
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0",
|
|
55
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0",
|
|
56
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0",
|
|
57
|
+
"@deepseek-ai/dsh-session-telemetry": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0",
|
|
58
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ~0.1.1-rc.0 || ~0.1.2-alpha.0"
|
|
58
59
|
},
|
|
59
60
|
"dependencies": {
|
|
60
61
|
"@deepseek-ai/schemastery": "3.18.1",
|