dsh-dlp 0.6.0 → 0.8.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 +13 -7
- package/lib/approval-reach.js +144 -0
- package/lib/cli.js +9 -2
- package/lib/detectors.js +30 -2
- package/lib/index.js +40 -20
- package/lib/redaction.js +83 -24
- package/lib/results.js +79 -34
- package/lib/sink.js +20 -3
- package/lib/telemetry.js +1 -1
- package/lib/types/approval-reach.d.ts +71 -0
- package/lib/types/cli.d.ts +6 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/redaction.d.ts +42 -6
- package/lib/types/results.d.ts +22 -4
- package/lib/types/sink.d.ts +22 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -26,7 +26,9 @@ built as an out-of-repo plugin.
|
|
|
26
26
|
credential.
|
|
27
27
|
7. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
|
|
28
28
|
`approval_mode: auto`, an `apply` whose approval is still pending. Both `ask` tiers are
|
|
29
|
-
prompts rather than controls: they live at `tools/pre-execute
|
|
29
|
+
prompts rather than controls: they live at `tools/pre-execute`, they can be neutralised, and
|
|
30
|
+
they abstain wherever the approval seam prompts nobody — which includes every install under
|
|
31
|
+
`DSH_PERMISSION_MODE=danger-full-access` and a stock headless install under any mode.
|
|
30
32
|
8. **Writes an audit record for every decision.** A redaction or denial names the rule, its
|
|
31
33
|
version, the offsets and a keyed hash; the three kinds with no matched region to describe —
|
|
32
34
|
an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
|
|
@@ -46,16 +48,18 @@ Three limits worth knowing before you rely on it:
|
|
|
46
48
|
|
|
47
49
|
- **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
|
|
48
50
|
registered ahead of ours. `ctx.tools.guard()` is order-independent only because it has no allow
|
|
49
|
-
arm.
|
|
51
|
+
arm. Result redaction registers with `{ prepend: true }` so it gets the last word over
|
|
52
|
+
listeners already registered — but a listener registering after it with the same option lands
|
|
53
|
+
ahead of it again.
|
|
50
54
|
- **The shell-command arm is advisory pattern-matching.** It tests the whole command line and
|
|
51
55
|
each of its tokens, so a credential path left *spelled* in the command is caught whatever
|
|
52
56
|
program would open it: `python3 -c "open('~/.ssh/id_rsa')"` is denied. Changing the spelling
|
|
53
57
|
defeats it — one glob character, quote-splitting, `find -exec`, a substitution that assembles
|
|
54
58
|
the path from pieces, a base64 round-trip, each verified. **Do not count this arm as a
|
|
55
59
|
control.**
|
|
56
|
-
- **Detection is pattern-based.** No entropy rule (measured, not assumed:
|
|
57
|
-
threshold
|
|
58
|
-
every rule in this package.
|
|
60
|
+
- **Detection is pattern-based.** No entropy rule (measured, not assumed: the lowest
|
|
61
|
+
false-positive-free threshold cannot flag anything shorter than 64–66 characters). Encoded
|
|
62
|
+
forms pass. A homoglyph defeats every rule in this package.
|
|
59
63
|
|
|
60
64
|
[The full list of limits →](https://charlotten7.github.io/dsh-dlp/)
|
|
61
65
|
|
|
@@ -70,8 +74,10 @@ dsh plugin --profile <name> add dsh-dlp
|
|
|
70
74
|
dsh --profile <name> --dump-config # the dsh-dlp row should appear
|
|
71
75
|
```
|
|
72
76
|
|
|
73
|
-
Any harness from `0.1.0-rc.6` onwards in the `0.1.x` line works
|
|
74
|
-
|
|
77
|
+
Any harness from `0.1.0-rc.6` onwards in the `0.1.x` line works, prereleases included. CI drives
|
|
78
|
+
the end-to-end suite against every published rc the peer ranges admit — `0.1.0-rc.6`, `rc.7`,
|
|
79
|
+
`rc.8`, `0.1.1-rc.1`, `0.1.1-rc.2` — and typechecks and builds against the `0.1.2` prerelease
|
|
80
|
+
line without running it end to end.
|
|
75
81
|
|
|
76
82
|
Pin `@deepseek-ai/dsh-headless` explicitly — its npm `latest` tag still points at `0.0.1-rc.1`.
|
|
77
83
|
The package ships a `cordis.patch.yml` bundle layer, so listing it in `dsh.profile.bundles` mounts
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an `ask` this plugin returns can still reach a human.
|
|
3
|
+
*
|
|
4
|
+
* The `ask` tier is documented as a prompt and never a block: its rules have a
|
|
5
|
+
* real false-positive rate, so a developer who is asked about `CLAUDE.md` or
|
|
6
|
+
* `.github/workflows/**` says yes and carries on. The tool registry resolves an
|
|
7
|
+
* `ask` through `ctx.get('approval')`, and three states of that seam turn the
|
|
8
|
+
* prompt into a denial nobody ever saw:
|
|
9
|
+
*
|
|
10
|
+
* 1. **No service composed.** `ToolRegistry.serviceAsk` keeps the historical
|
|
11
|
+
* degrade to `deny` when `ctx.get('approval')` is `undefined`.
|
|
12
|
+
* 2. **The policy in force is `'never'`.** `ApprovalService.decide` resolves
|
|
13
|
+
* `'rejected'` before any dispatch — its own JSDoc calls this "never prompt
|
|
14
|
+
* anyone". The shipped `dsh-base` bundle selects it for every install under
|
|
15
|
+
* `DSH_PERMISSION_MODE=danger-full-access`, which is the unattended posture.
|
|
16
|
+
* 3. **Nothing composed on `approval/request`.** The waterfall falls through to
|
|
17
|
+
* the fail-closed `'unavailable'`, which the registry maps to `deny`. The
|
|
18
|
+
* shipped `dsh-headless` bundle composes no answerer, so this is the state
|
|
19
|
+
* of a stock headless install under every other permission mode.
|
|
20
|
+
*
|
|
21
|
+
* In all three the call is stopped with no human involved, which is exactly
|
|
22
|
+
* what this tier was designed not to do. Each is decided here so the caller can
|
|
23
|
+
* abstain instead — the same thing it already did for (1) alone.
|
|
24
|
+
*
|
|
25
|
+
* **Every check reports "reachable" when it cannot tell.** Abstaining removes a
|
|
26
|
+
* prompt, so an unreadable service, a missing session, or a `ctx.get` that
|
|
27
|
+
* returned something other than the service read here all keep the ask. Only a
|
|
28
|
+
* positive reading of one of the three states above abstains.
|
|
29
|
+
* @module dsh-dlp/approval-reach
|
|
30
|
+
*/
|
|
31
|
+
/** The event `ApprovalService.decide` dispatches its answerer waterfall on. */
|
|
32
|
+
const APPROVAL_REQUEST = 'approval/request';
|
|
33
|
+
/**
|
|
34
|
+
* Whether every ask for this session resolves without prompting anyone.
|
|
35
|
+
*
|
|
36
|
+
* `'never'` is the only policy whose outcome is knowable without asking, and
|
|
37
|
+
* the service decides it before dispatch, so no composed answerer can change
|
|
38
|
+
* it. Anything else — including a value this build does not recognise — leaves
|
|
39
|
+
* the answerers to decide and is therefore not a positive reading.
|
|
40
|
+
* @param approval - whatever `ctx.get('approval')` returned.
|
|
41
|
+
* @param session - the calling agent's session, or `undefined` when the call has no agent.
|
|
42
|
+
* @returns `true` only when the policy in force was read and is `'never'`.
|
|
43
|
+
*/
|
|
44
|
+
function policyIsNever(approval, session) {
|
|
45
|
+
// Without a session there is no override to fold, and the configured default
|
|
46
|
+
// alone cannot tell a session that never switched from one that switched to
|
|
47
|
+
// 'ask' — so an agent-less call keeps its prompt.
|
|
48
|
+
if (session === undefined)
|
|
49
|
+
return false;
|
|
50
|
+
const service = approval;
|
|
51
|
+
let policy;
|
|
52
|
+
try {
|
|
53
|
+
policy = service.overrideOf === undefined ? undefined : service.overrideOf(session) ?? service.config?.policy;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Only a service without the members ApprovalSurface describes reaches
|
|
57
|
+
// here. It answers nothing about whether a human can be asked, and
|
|
58
|
+
// this runs inside a live tool call: a diagnostic read must not fail it.
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return policy === 'never';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* How many listeners are composed on the answerer waterfall.
|
|
65
|
+
*
|
|
66
|
+
* The count is read across the whole context tree, before the scope filter
|
|
67
|
+
* `ApprovalService.decide` applies. That over-counts — an agent-scoped answerer
|
|
68
|
+
* belonging to a different agent is included — and over-counting is the safe
|
|
69
|
+
* direction here, because only a count of zero abstains and zero listeners
|
|
70
|
+
* cannot be filtered into some.
|
|
71
|
+
*
|
|
72
|
+
* `EventsService._hooks` is a declared public field of the exported class, so a
|
|
73
|
+
* Cordis that stops carrying it fails `typecheck` and `build` in this package
|
|
74
|
+
* rather than being misread at a user's install; `@deepseek-ai/cordis` is
|
|
75
|
+
* pinned to one exact version (ADR §19) for the same reason.
|
|
76
|
+
* @param ctx - the plugin's context; every context in one tree shares the bus.
|
|
77
|
+
* @returns the number of composed answerers.
|
|
78
|
+
*/
|
|
79
|
+
function composedAnswerers(ctx) {
|
|
80
|
+
return ctx.events._hooks[APPROVAL_REQUEST]?.length ?? 0;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Decide whether an ask would reach a human, at the moment of the decision.
|
|
84
|
+
*
|
|
85
|
+
* Evaluated per call rather than at mount: a session switches policy mid-run
|
|
86
|
+
* through `approval/policy`, the override is per session, and an answerer can
|
|
87
|
+
* be composed or disposed while the harness runs. A mount-time answer would be
|
|
88
|
+
* a cached guess at all three.
|
|
89
|
+
* @param ctx - the plugin's context, for the service and the event bus.
|
|
90
|
+
* @param session - the calling agent's session, or `undefined` when the call has no agent.
|
|
91
|
+
* @returns whether to ask, or which state stopped the ask from reaching anyone.
|
|
92
|
+
*/
|
|
93
|
+
export function askReach(ctx, session) {
|
|
94
|
+
const approval = ctx.get('approval');
|
|
95
|
+
if (approval === undefined)
|
|
96
|
+
return { kind: 'unreachable', cause: 'no-service' };
|
|
97
|
+
// Policy first, matching the service's own order: it decides 'never' before
|
|
98
|
+
// dispatching, so that is the state an operator sees reported.
|
|
99
|
+
if (policyIsNever(approval, session))
|
|
100
|
+
return { kind: 'unreachable', cause: 'policy-never' };
|
|
101
|
+
if (composedAnswerers(ctx) === 0)
|
|
102
|
+
return { kind: 'unreachable', cause: 'no-answerer' };
|
|
103
|
+
return { kind: 'reachable' };
|
|
104
|
+
}
|
|
105
|
+
/** What abstaining does, said once and identically for all three states. */
|
|
106
|
+
const CONSEQUENCE = 'This tier abstains rather than becoming the silent hard deny it was designed not to be: a'
|
|
107
|
+
+ ' write to a behaviour-changing config path, and a call that switches its own confirmation off, are allowed'
|
|
108
|
+
+ ' through with no prompt, and each one is recorded in the audit sink as "pre-execute-ask-abstained". The'
|
|
109
|
+
+ ' guard floor is unaffected. Set configWriteAsk: false and approvalSuppressionAsk: false to turn this tier'
|
|
110
|
+
+ ' off entirely.';
|
|
111
|
+
/**
|
|
112
|
+
* What to tell the operator when the ask tier has nowhere to ask.
|
|
113
|
+
*
|
|
114
|
+
* Each line names the state, what to change to get the prompt back, and the
|
|
115
|
+
* consequence. Reported on `process.stderr` as well as `ctx.logger` for the
|
|
116
|
+
* reason ADR §7 records: the logger's default exporter is an in-memory ring
|
|
117
|
+
* buffer and no shipped bundle mounts a console exporter.
|
|
118
|
+
* @param cause - the state {@link askReach} read.
|
|
119
|
+
* @returns the whole line to report.
|
|
120
|
+
*/
|
|
121
|
+
export function approvalSeamNotice(cause) {
|
|
122
|
+
const prefix = 'dsh-dlp: the ask tier (configWriteAsk, approvalSuppressionAsk) is enabled, but';
|
|
123
|
+
switch (cause) {
|
|
124
|
+
case 'no-service':
|
|
125
|
+
return `${prefix} no approval service is mounted, so the tool registry would resolve an ask as a denial`
|
|
126
|
+
+ ' with nothing shown to anyone. Composing an approval service and an answerer puts the prompt back.'
|
|
127
|
+
+ ` ${CONSEQUENCE}`;
|
|
128
|
+
case 'policy-never':
|
|
129
|
+
return `${prefix} the approval policy in force is "never", which resolves every ask as rejected without`
|
|
130
|
+
+ ' prompting anyone. DSH_PERMISSION_MODE=danger-full-access selects that policy in the shipped dsh-base'
|
|
131
|
+
+ ' bundle; run under another permission mode, or set the approval row\'s policy to "ask", for these'
|
|
132
|
+
+ ` calls to be asked about. ${CONSEQUENCE}`;
|
|
133
|
+
case 'no-answerer':
|
|
134
|
+
return `${prefix} nothing is composed on the approval/request waterfall, which fails every ask closed as`
|
|
135
|
+
+ ' unavailable and denies the call with nothing shown to anyone. The shipped dsh-headless bundle'
|
|
136
|
+
+ ' composes no answerer; a surface that answers approvals, such as the Host API proxy or the ACP'
|
|
137
|
+
+ ` bridge, puts the prompt back. ${CONSEQUENCE}`;
|
|
138
|
+
/* v8 ignore next 4 -- unreachable while `AskUnreachable` stays closed; the arm exists so adding a variant fails the build. */
|
|
139
|
+
default: {
|
|
140
|
+
const unhandled = cause;
|
|
141
|
+
throw new TypeError(`dsh-dlp: unhandled ask-tier state ${JSON.stringify(unhandled)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
package/lib/cli.js
CHANGED
|
@@ -74,6 +74,7 @@ export function parseRecord(line) {
|
|
|
74
74
|
return undefined;
|
|
75
75
|
const tool = stringField(record, 'tool');
|
|
76
76
|
const sessionId = stringField(record, 'sessionId');
|
|
77
|
+
const askUnreachable = stringField(record, 'askUnreachable');
|
|
77
78
|
return {
|
|
78
79
|
time,
|
|
79
80
|
kind,
|
|
@@ -81,6 +82,7 @@ export function parseRecord(line) {
|
|
|
81
82
|
...sessionId === undefined ? {} : { sessionId },
|
|
82
83
|
ruleIds: ruleIdsOf(record),
|
|
83
84
|
unicode: unicodeOf(record),
|
|
85
|
+
...askUnreachable === undefined ? {} : { askUnreachable },
|
|
84
86
|
};
|
|
85
87
|
}
|
|
86
88
|
/** Text printed for `--help` and alongside a usage error. */
|
|
@@ -255,12 +257,17 @@ export function formatReport(records, unreadable, options) {
|
|
|
255
257
|
lines.push(` ${unreadable} line(s) were not readable as records`);
|
|
256
258
|
if (selected.length === 0)
|
|
257
259
|
return lines;
|
|
258
|
-
lines.push(...section('by decision', tally(selected, record => [record.kind])), ...section('by rule', tally(selected, record => record.ruleIds)), ...section('by tool', tally(selected, record => record.tool === undefined ? [] : [record.tool])), ...section('results carrying invisible characters', tally(selected, record => Object.keys(record.unicode)))
|
|
260
|
+
lines.push(...section('by decision', tally(selected, record => [record.kind])), ...section('by rule', tally(selected, record => record.ruleIds)), ...section('by tool', tally(selected, record => record.tool === undefined ? [] : [record.tool])), ...section('results carrying invisible characters', tally(selected, record => Object.keys(record.unicode))),
|
|
261
|
+
// The state behind an abstention, which nothing else in the report shows.
|
|
262
|
+
// An abstention is the one outcome where a documented prompt did not
|
|
263
|
+
// happen and the call ran anyway, so the operator needs the state by name.
|
|
264
|
+
...section('asks that reached nobody', tally(selected, record => record.askUnreachable === undefined ? [] : [record.askUnreachable])));
|
|
259
265
|
const recent = [...selected].sort((left, right) => right.time - left.time).slice(0, RECENT_LIMIT);
|
|
260
266
|
lines.push('', `most recent ${recent.length}`);
|
|
261
267
|
for (const record of recent) {
|
|
262
268
|
const rules = record.ruleIds.length === 0 ? '-' : record.ruleIds.join(', ');
|
|
263
|
-
|
|
269
|
+
const abstention = record.askUnreachable === undefined ? '' : ` no prompt: ${record.askUnreachable}`;
|
|
270
|
+
lines.push(` ${new Date(record.time).toISOString()} ${record.kind} ${record.tool ?? '-'} ${rules}${abstention}`);
|
|
264
271
|
}
|
|
265
272
|
return lines;
|
|
266
273
|
}
|
package/lib/detectors.js
CHANGED
|
@@ -45,7 +45,23 @@ export const DENY_SEVERITY = 'high';
|
|
|
45
45
|
export const SYNC_RULES = [
|
|
46
46
|
{ id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
|
|
47
47
|
{ id: 'dsh-dlp/aws-secret-access-key', version: 1, severity: 'critical', pattern: /\baws_secret_access_key\b\s*[=:]\s*["']?[A-Za-z0-9/+=]{40}["']?/gi },
|
|
48
|
-
|
|
48
|
+
// Three formats, and the stateless one is first because the opaque
|
|
49
|
+
// alternative would otherwise claim its `ghs_` prefix and stop at the app
|
|
50
|
+
// id. GitHub's 2026-04-24 changelog gives the shape as `ghs_APPID_JWT`,
|
|
51
|
+
// "~520 characters" carrying two dots, rolled out from 2026-04-27 to late
|
|
52
|
+
// June; the opaque 40-character format stays because existing tokens
|
|
53
|
+
// "continue to work until they expire". GitHub's own suggested
|
|
54
|
+
// `ghs_[A-Za-z0-9.\-_]{36,}` is deliberately not what this uses: on a floor
|
|
55
|
+
// rule a false positive is a denial, and that class matches an ordinary
|
|
56
|
+
// dotted file name — `ghs_report-2026-04-24.summary-eu-west-1.json` is 40
|
|
57
|
+
// characters past the prefix. Anchoring on the JWT the changelog describes
|
|
58
|
+
// costs nothing a real token has.
|
|
59
|
+
{
|
|
60
|
+
id: 'dsh-dlp/github-token',
|
|
61
|
+
version: 2,
|
|
62
|
+
severity: 'critical',
|
|
63
|
+
pattern: /\b(?:ghs_[A-Za-z0-9]{1,32}_eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])|gh[pousr]_[A-Za-z0-9]{36,251}\b|github_pat_[A-Za-z0-9_]{22,251}\b)/g,
|
|
64
|
+
},
|
|
49
65
|
{ id: 'dsh-dlp/slack-token', version: 1, severity: 'critical', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
50
66
|
{ id: 'dsh-dlp/stripe-secret-key', version: 1, severity: 'critical', pattern: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/g },
|
|
51
67
|
{ id: 'dsh-dlp/anthropic-api-key', version: 1, severity: 'critical', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
@@ -89,7 +105,19 @@ export const SYNC_RULES = [
|
|
|
89
105
|
{ id: 'dsh-dlp/cloudflare-api-token', version: 1, severity: 'critical', pattern: /\bcf(?:ut|at|k)_[A-Za-z0-9_-]{40,}/g },
|
|
90
106
|
{ id: 'dsh-dlp/notion-token', version: 1, severity: 'critical', pattern: /\bntn_[A-Za-z0-9]{40,}/g },
|
|
91
107
|
{ id: 'dsh-dlp/private-key-block', version: 1, severity: 'critical', pattern: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g },
|
|
92
|
-
|
|
108
|
+
// Anchored on the base64url alphabet rather than on word characters. `\b`
|
|
109
|
+
// was wrong in both directions: `_` is a word character, so a JWT behind any
|
|
110
|
+
// `prefix_` was invisible, and a signature's trailing `-` was trimmed off the
|
|
111
|
+
// reported span. A letter or digit before `eyJ` still refuses, which is what
|
|
112
|
+
// keeps a match from starting in the middle of a longer token; `_` and `-`
|
|
113
|
+
// are base64url characters but are separators far more often, and the two
|
|
114
|
+
// literal dots this pattern requires are in no base64url run.
|
|
115
|
+
{
|
|
116
|
+
id: 'dsh-dlp/json-web-token',
|
|
117
|
+
version: 2,
|
|
118
|
+
severity: 'high',
|
|
119
|
+
pattern: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])/g,
|
|
120
|
+
},
|
|
93
121
|
{ id: 'dsh-dlp/credential-url', version: 1, severity: 'high', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]+@[^\s/]+/gi },
|
|
94
122
|
// Webhook URLs are bearer credentials whose path segment is the secret. They
|
|
95
123
|
// are in tier 1 rather than left to secretlint because the telemetry seam is
|
package/lib/index.js
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
* config paths and for calls carrying an argument that switches their own
|
|
14
14
|
* confirmation off. Deliberately here rather than on the floor: its rules
|
|
15
15
|
* have a real false-positive rate and the floor cannot ask. Neutralizable,
|
|
16
|
-
* and it abstains entirely
|
|
16
|
+
* and it abstains entirely wherever the approval seam cannot prompt anyone.
|
|
17
17
|
* 3. `tools/post-execute` — result redaction, applied before the `tool/result`
|
|
18
18
|
* session event is appended, so the durable log records the redacted copy;
|
|
19
19
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
20
|
+
* Prepended, so it redacts what the rest of the waterfall returned; a
|
|
21
|
+
* listener registering later with the same option still runs ahead of it.
|
|
20
22
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
21
23
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
22
24
|
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
|
@@ -43,6 +45,7 @@ import { neutralizeImageStream } from "./images.js";
|
|
|
43
45
|
import { ExecutionSnapshots, mutationReason } from "./mutation.js";
|
|
44
46
|
import { evaluateConfigWrite } from "./config-writes.js";
|
|
45
47
|
import { evaluateApprovalSuppression } from "./approvals.js";
|
|
48
|
+
import { approvalSeamNotice, askReach } from "./approval-reach.js";
|
|
46
49
|
import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
|
|
47
50
|
import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
|
|
48
51
|
import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
|
|
@@ -238,25 +241,20 @@ export function apply(ctx, config) {
|
|
|
238
241
|
return verdict.reason;
|
|
239
242
|
}), 'dsh-dlp guard floor');
|
|
240
243
|
/**
|
|
241
|
-
* Report once that the `ask` tier has nowhere to ask.
|
|
244
|
+
* Report once per state that the `ask` tier has nowhere to ask.
|
|
242
245
|
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* rather than at mount, because by then the harness is running and an absent
|
|
249
|
-
* service is conclusive rather than a load order.
|
|
246
|
+
* Latched per state rather than once overall: the three states have
|
|
247
|
+
* different fixes, a session can move between them mid-run by switching its
|
|
248
|
+
* approval policy, and a single latch would leave the first one reported
|
|
249
|
+
* standing for a different one afterwards. `approval-reach.ts` records why
|
|
250
|
+
* each is a state in which an ask reaches nobody.
|
|
250
251
|
*/
|
|
251
|
-
|
|
252
|
-
const discloseApprovalSeam = () => {
|
|
253
|
-
if (approvalSeamReported)
|
|
252
|
+
const approvalSeamReported = new Set();
|
|
253
|
+
const discloseApprovalSeam = (cause) => {
|
|
254
|
+
if (approvalSeamReported.has(cause))
|
|
254
255
|
return;
|
|
255
|
-
approvalSeamReported
|
|
256
|
-
notice(ctx,
|
|
257
|
-
+ ' is mounted, so an ask would degrade to a denial. This tier abstains instead: a write to a'
|
|
258
|
-
+ ' behaviour-changing config path, and a call that switches its own confirmation off, are allowed through'
|
|
259
|
-
+ ' with no prompt. The guard floor is unaffected.');
|
|
256
|
+
approvalSeamReported.add(cause);
|
|
257
|
+
notice(ctx, approvalSeamNotice(cause));
|
|
260
258
|
};
|
|
261
259
|
if (policy.configWriteAsk || policy.approvalSuppressionAsk) {
|
|
262
260
|
// Registered ahead of the breadth tier, so a call that is both a config
|
|
@@ -280,8 +278,25 @@ export function apply(ctx, config) {
|
|
|
280
278
|
// and would file the decision as an ask rather than as a guard denial.
|
|
281
279
|
if (safeEvaluateGuard(exec, policy, hasher) !== undefined)
|
|
282
280
|
return decision;
|
|
283
|
-
|
|
284
|
-
|
|
281
|
+
// Asked at decision time, never at mount: the session's policy is a fold
|
|
282
|
+
// over its own log and can change mid-run, and an answerer can be
|
|
283
|
+
// composed or disposed while the harness runs.
|
|
284
|
+
const reach = askReach(ctx, exec.agent?.session);
|
|
285
|
+
if (reach.kind === 'unreachable') {
|
|
286
|
+
discloseApprovalSeam(reach.cause);
|
|
287
|
+
// An abstention allowed a call this tier would have asked about, so it
|
|
288
|
+
// is recorded rather than left silent. Its own kind, not a flag on
|
|
289
|
+
// `pre-execute-ask`: `dsh-dlp report` counts by kind, and an ask that
|
|
290
|
+
// reached nobody must not be counted as a prompt that happened.
|
|
291
|
+
sink.write({
|
|
292
|
+
v: RECORD_VERSION,
|
|
293
|
+
time: new Date().toISOString(),
|
|
294
|
+
kind: 'pre-execute-ask-abstained',
|
|
295
|
+
decisionId: newDecisionId(),
|
|
296
|
+
...identity(exec),
|
|
297
|
+
ruleId: finding.rule.id,
|
|
298
|
+
askUnreachable: reach.cause,
|
|
299
|
+
});
|
|
285
300
|
return decision;
|
|
286
301
|
}
|
|
287
302
|
sink.write({
|
|
@@ -315,6 +330,11 @@ export function apply(ctx, config) {
|
|
|
315
330
|
});
|
|
316
331
|
}
|
|
317
332
|
if (policy.resultRedaction) {
|
|
333
|
+
// Prepended for the same reason as the snapshot listener above, and with
|
|
334
|
+
// the same limit: listeners run outermost-first, so registering ahead of
|
|
335
|
+
// the chain is what lets this one redact the decision the rest of the
|
|
336
|
+
// waterfall settled on rather than have its own replaced afterwards. A
|
|
337
|
+
// listener registered later with the same option still lands ahead of it.
|
|
318
338
|
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
319
339
|
// The live definition, so a schema the deployment's own tool declares is
|
|
320
340
|
// read from the registry rather than assumed. `exec.agent` is the scope
|
|
@@ -342,7 +362,7 @@ export function apply(ctx, config) {
|
|
|
342
362
|
});
|
|
343
363
|
}
|
|
344
364
|
return redacted.decision;
|
|
345
|
-
});
|
|
365
|
+
}, { prepend: true });
|
|
346
366
|
}
|
|
347
367
|
if (policy.remoteImageNeutralization) {
|
|
348
368
|
ctx.on('llm/stream', (options, next) => neutralizeImageStream(next(), (host) => {
|
package/lib/redaction.js
CHANGED
|
@@ -166,10 +166,37 @@ function pointerSegment(segment) {
|
|
|
166
166
|
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
167
167
|
}
|
|
168
168
|
/**
|
|
169
|
-
*
|
|
169
|
+
* Replace every detected region of every string inside one structure.
|
|
170
170
|
*
|
|
171
171
|
* Object *keys* are left alone: a key is structure, not payload, and renaming
|
|
172
|
-
* one would break the owning tool's `output.schema` on re-validation.
|
|
172
|
+
* one would break the owning tool's `output.schema` on re-validation. The
|
|
173
|
+
* structure is otherwise rebuilt unchanged — only string leaves differ — which
|
|
174
|
+
* is what lets the callers re-type the result as what they handed in.
|
|
175
|
+
* @param node - the structure to walk.
|
|
176
|
+
* @param path - JSON pointer of this node.
|
|
177
|
+
* @param scan - synchronous detector applied to each string.
|
|
178
|
+
* @param hasher - mints each span's keyed hash.
|
|
179
|
+
* @param spans - collects every span replaced anywhere in the walk.
|
|
180
|
+
* @returns the same structure with each detected region replaced.
|
|
181
|
+
*/
|
|
182
|
+
function redactNode(node, path, scan, hasher, spans) {
|
|
183
|
+
if (typeof node === 'string') {
|
|
184
|
+
const redacted = redactText(node, scan(node), hasher, path);
|
|
185
|
+
if (redacted.spans.length === 0)
|
|
186
|
+
return node;
|
|
187
|
+
spans.push(...redacted.spans);
|
|
188
|
+
return redacted.text;
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(node)) {
|
|
191
|
+
return node.map((item, index) => redactNode(item, `${path}/${index}`, scan, hasher, spans));
|
|
192
|
+
}
|
|
193
|
+
if (typeof node === 'object' && node !== null) {
|
|
194
|
+
return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, redactNode(item, `${path}/${pointerSegment(key)}`, scan, hasher, spans)]));
|
|
195
|
+
}
|
|
196
|
+
return node;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Redact every string inside a JSON value, at any depth.
|
|
173
200
|
* @param value - the structure to redact.
|
|
174
201
|
* @param scan - synchronous detector applied to each string.
|
|
175
202
|
* @param hasher - mints each span's keyed hash.
|
|
@@ -177,26 +204,10 @@ function pointerSegment(segment) {
|
|
|
177
204
|
*/
|
|
178
205
|
export function redactJson(value, scan, hasher) {
|
|
179
206
|
const spans = [];
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (redacted.spans.length === 0)
|
|
185
|
-
return node;
|
|
186
|
-
changed = true;
|
|
187
|
-
spans.push(...redacted.spans);
|
|
188
|
-
return redacted.text;
|
|
189
|
-
}
|
|
190
|
-
if (Array.isArray(node)) {
|
|
191
|
-
return node.map((item, index) => walk(item, `${path}/${index}`));
|
|
192
|
-
}
|
|
193
|
-
if (typeof node === 'object' && node !== null) {
|
|
194
|
-
return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, walk(item, `${path}/${pointerSegment(key)}`)]));
|
|
195
|
-
}
|
|
196
|
-
return node;
|
|
197
|
-
};
|
|
198
|
-
const result = walk(value, '');
|
|
199
|
-
return { value: result, spans, changed };
|
|
207
|
+
// `redactNode` replaces strings with strings and rebuilds everything else as
|
|
208
|
+
// it found it, so what comes back is the same JSON shape it was handed.
|
|
209
|
+
const result = redactNode(value, '', scan, hasher, spans);
|
|
210
|
+
return { value: result, spans, changed: spans.length > 0 };
|
|
200
211
|
}
|
|
201
212
|
/**
|
|
202
213
|
* Redact the text blocks of one model-facing content array. Non-text blocks
|
|
@@ -205,15 +216,16 @@ export function redactJson(value, scan, hasher) {
|
|
|
205
216
|
* @param blocks - the content blocks to redact.
|
|
206
217
|
* @param scan - synchronous detector applied to each block's text.
|
|
207
218
|
* @param hasher - mints each span's keyed hash.
|
|
219
|
+
* @param pathPrefix - JSON pointer the recorded pointers hang off, for blocks that are not the decision's own.
|
|
208
220
|
* @returns the redacted blocks, the spans replaced, and whether anything changed.
|
|
209
221
|
*/
|
|
210
|
-
export function redactContent(blocks, scan, hasher) {
|
|
222
|
+
export function redactContent(blocks, scan, hasher, pathPrefix = '') {
|
|
211
223
|
const spans = [];
|
|
212
224
|
let changed = false;
|
|
213
225
|
const content = blocks.map((block, index) => {
|
|
214
226
|
if (block.type !== 'text')
|
|
215
227
|
return block;
|
|
216
|
-
const redacted = redactText(block.text, scan(block.text), hasher,
|
|
228
|
+
const redacted = redactText(block.text, scan(block.text), hasher, `${pathPrefix}/${index}/text`);
|
|
217
229
|
if (redacted.spans.length === 0)
|
|
218
230
|
return block;
|
|
219
231
|
changed = true;
|
|
@@ -222,3 +234,50 @@ export function redactContent(blocks, scan, hasher) {
|
|
|
222
234
|
});
|
|
223
235
|
return { content, spans, changed };
|
|
224
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Source fields that say what a message *is* rather than what it says. `kind`
|
|
239
|
+
* selects the source arm, `form` selects the fields that arm carries, and
|
|
240
|
+
* `plugin` names the producer; replacing one of those would change the
|
|
241
|
+
* message's identity rather than redact its text.
|
|
242
|
+
*/
|
|
243
|
+
const MESSAGE_SOURCE_STRUCTURE = new Set(['kind', 'form', 'plugin']);
|
|
244
|
+
/**
|
|
245
|
+
* Redact the text of the `UserMessage`s a tool decision carries.
|
|
246
|
+
*
|
|
247
|
+
* A message's payload is its model-facing `content` blocks and whatever text
|
|
248
|
+
* its `source` records: a `snapshot` source repeats the block text in
|
|
249
|
+
* `sections[].text`, a `notice` source repeats its opening in `summary`, and
|
|
250
|
+
* `MessageSourceMap` is merge-extensible, so a plugin's own source kind may
|
|
251
|
+
* carry text of its own. All of it is appended to the session log with the
|
|
252
|
+
* message, so redacting the blocks alone would leave a copy behind. Both
|
|
253
|
+
* halves are walked with the same scan and the same hasher, and identical text
|
|
254
|
+
* yields an identical placeholder, so the copies stay in step.
|
|
255
|
+
*
|
|
256
|
+
* `id` and `role` are left alone: the inbox addresses a message by its id.
|
|
257
|
+
* A message with nothing to replace is returned as the object it arrived as.
|
|
258
|
+
* @param messages - the contexts to redact, in the order they are attached.
|
|
259
|
+
* @param scan - synchronous detector applied to each string.
|
|
260
|
+
* @param hasher - mints each span's keyed hash.
|
|
261
|
+
* @param pathPrefix - JSON pointer the recorded pointers hang off.
|
|
262
|
+
* @returns the redacted messages, the spans replaced, and whether anything changed.
|
|
263
|
+
*/
|
|
264
|
+
export function redactUserMessages(messages, scan, hasher, pathPrefix) {
|
|
265
|
+
const spans = [];
|
|
266
|
+
const redacted = messages.map((message, index) => {
|
|
267
|
+
const path = `${pathPrefix}/${index}`;
|
|
268
|
+
const before = spans.length;
|
|
269
|
+
const content = redactContent(message.content, scan, hasher, `${path}/content`);
|
|
270
|
+
spans.push(...content.spans);
|
|
271
|
+
// Rebuilt entry by entry so the discriminants pass through untouched;
|
|
272
|
+
// `redactNode` returns each remaining field as the shape it was given, so
|
|
273
|
+
// the result is the same source with redacted text.
|
|
274
|
+
const source = Object.fromEntries(Object.entries(message.source).map(([key, value]) => [
|
|
275
|
+
key,
|
|
276
|
+
MESSAGE_SOURCE_STRUCTURE.has(key)
|
|
277
|
+
? value
|
|
278
|
+
: redactNode(value, `${path}/source/${pointerSegment(key)}`, scan, hasher, spans),
|
|
279
|
+
]));
|
|
280
|
+
return spans.length === before ? message : { ...message, content: content.content, source };
|
|
281
|
+
});
|
|
282
|
+
return { messages: redacted, spans, changed: spans.length > 0 };
|
|
283
|
+
}
|
package/lib/results.js
CHANGED
|
@@ -4,15 +4,18 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Both are best-effort by construction. A `tools/pre-execute` listener
|
|
6
6
|
* registered ahead of ours can return without calling `next()` and neutralize
|
|
7
|
-
* the breadth tier
|
|
8
|
-
*
|
|
7
|
+
* the breadth tier. Result redaction registers with `{ prepend: true }`, so it
|
|
8
|
+
* redacts whatever the rest of the waterfall settled on rather than having its
|
|
9
|
+
* own decision replaced afterwards — but `prepend` unshifts, so a listener
|
|
10
|
+
* that registers later with the same option still lands ahead of it. Only
|
|
11
|
+
* `ctx.tools.guard()` is order-independent, and it cannot rewrite a result.
|
|
9
12
|
* What these seams buy is breadth: they can await, so `@secretlint/core`'s
|
|
10
13
|
* whole rule set applies here and not in the guard.
|
|
11
14
|
* @module dsh-dlp/results
|
|
12
15
|
*/
|
|
13
16
|
import { DENY_SEVERITY, countUnicodeIndicators, scanAll, scanSync, severityRank, } from "./detectors.js";
|
|
14
17
|
import { isEgressCapable } from "./paths.js";
|
|
15
|
-
import { nestedStrings, redactContent, redactJson } from "./redaction.js";
|
|
18
|
+
import { nestedStrings, redactContent, redactJson, redactUserMessages, } from "./redaction.js";
|
|
16
19
|
import { redactionBreaksSchema } from "./schema.js";
|
|
17
20
|
/**
|
|
18
21
|
* Separator the strings of one result are rendered with before the
|
|
@@ -76,6 +79,17 @@ async function prepareScan(strings, policy) {
|
|
|
76
79
|
function contentStrings(blocks) {
|
|
77
80
|
return blocks.flatMap(block => block.type === 'text' ? [block.text] : []);
|
|
78
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Text one set of `additionalContexts` carries: the model-facing blocks, plus
|
|
84
|
+
* whatever text the source records beside them — a `snapshot` source repeats
|
|
85
|
+
* the block text in `sections[].text`, a `notice` source its opening in
|
|
86
|
+
* `summary`, and a plugin's own source kind may carry more.
|
|
87
|
+
* @param messages - the contexts attached to a decision or ferried on a result.
|
|
88
|
+
* @returns every string those messages would put in front of the model or into the log.
|
|
89
|
+
*/
|
|
90
|
+
function messageStrings(messages) {
|
|
91
|
+
return messages.flatMap(message => [...contentStrings(message.content), ...nestedStrings(message.source)]);
|
|
92
|
+
}
|
|
79
93
|
/**
|
|
80
94
|
* Strings the harness keeps in the durable result when the decision does not
|
|
81
95
|
* replace the canonical value.
|
|
@@ -121,8 +135,23 @@ function withheldFeedback(spans) {
|
|
|
121
135
|
* persisted surfaces are already clean — a failed result, which has no
|
|
122
136
|
* value, or a success whose secret exists only in the rendered content.
|
|
123
137
|
* - `block` is the fallback when neither works: a failed result whose `meta`
|
|
124
|
-
* carries a secret,
|
|
125
|
-
* Blocking replaces the whole result, which
|
|
138
|
+
* carries a secret, a value that still scans dirty after redaction, or a
|
|
139
|
+
* context the tool body deferred. Blocking replaces the whole result, which
|
|
140
|
+
* is the only way to drop either.
|
|
141
|
+
*
|
|
142
|
+
* `additionalContexts` reach both the model and the log — the loop hands each
|
|
143
|
+
* one to the inbox, which appends an `agent/inbox/spliced` event carrying the
|
|
144
|
+
* whole message — and they arrive from two places that are not equally
|
|
145
|
+
* reachable:
|
|
146
|
+
*
|
|
147
|
+
* - the ones this decision carries, which the returned decision owns, so they
|
|
148
|
+
* are redacted in place. Rewriting them is a rewrite of another listener's
|
|
149
|
+
* data, which is the same thing the value arm already does to a downstream
|
|
150
|
+
* `accept{content}`, and it costs a placeholder rather than a lost result;
|
|
151
|
+
* - the ones the tool body deferred, which the registry concatenates ahead of
|
|
152
|
+
* the decision's own on *every* accept arm. No accept can rewrite or drop
|
|
153
|
+
* them, so a dirty one is withheld. That is `meta`'s case exactly, and it is
|
|
154
|
+
* the one place scanning contexts can cost a successful result.
|
|
126
155
|
*
|
|
127
156
|
* Replacing the value is re-validated by the registry against the tool's
|
|
128
157
|
* `output.schema`, and a schema that pins the redacted string rejects it. That
|
|
@@ -142,21 +171,30 @@ function withheldFeedback(spans) {
|
|
|
142
171
|
* @returns the decision to return, the spans replaced, and scan completeness.
|
|
143
172
|
*/
|
|
144
173
|
export async function redactDecision(decision, result, policy, hasher, outputSchema) {
|
|
174
|
+
const attached = decision.additionalContexts ?? [];
|
|
145
175
|
if (decision.kind === 'block') {
|
|
146
|
-
|
|
176
|
+
// A block exposes only the blocking decision's own contexts, so the
|
|
177
|
+
// feedback and those are the whole surface this arm can put in front of
|
|
178
|
+
// the model.
|
|
179
|
+
const prepared = await prepareScan([...contentStrings(decision.feedback), ...messageStrings(attached)], policy);
|
|
147
180
|
const redacted = redactContent(decision.feedback, prepared.scan, hasher);
|
|
181
|
+
const redactedContexts = redactUserMessages(attached, prepared.scan, hasher, '/additionalContexts');
|
|
148
182
|
return {
|
|
149
|
-
decision: redacted.changed
|
|
150
|
-
|
|
183
|
+
decision: redacted.changed || redactedContexts.changed
|
|
184
|
+
? {
|
|
185
|
+
...decision,
|
|
186
|
+
feedback: redacted.content,
|
|
187
|
+
...redactedContexts.changed ? { additionalContexts: redactedContexts.messages } : {},
|
|
188
|
+
}
|
|
189
|
+
: decision,
|
|
190
|
+
spans: [...redacted.spans, ...redactedContexts.spans],
|
|
151
191
|
truncatedScan: prepared.truncated,
|
|
152
192
|
indicators: prepared.indicators,
|
|
153
193
|
};
|
|
154
194
|
}
|
|
155
195
|
const replacedValue = Object.hasOwn(decision, 'value') ? decision.value : undefined;
|
|
156
196
|
const replacedContent = Object.hasOwn(decision, 'content') ? decision.content : undefined;
|
|
157
|
-
const
|
|
158
|
-
? {}
|
|
159
|
-
: { additionalContexts: decision.additionalContexts };
|
|
197
|
+
const deferred = result.additionalContexts ?? [];
|
|
160
198
|
// The value the harness will persist: a downstream replacement when there is
|
|
161
199
|
// one, otherwise the tool's own. A failed result has no value at all.
|
|
162
200
|
const value = replacedValue ?? (result.isError ? undefined : result.value);
|
|
@@ -164,31 +202,39 @@ export async function redactDecision(decision, result, policy, hasher, outputSch
|
|
|
164
202
|
const persisted = replacedValue === undefined
|
|
165
203
|
? persistedStrings(result)
|
|
166
204
|
: [...nestedStrings(replacedValue), ...result.meta === undefined ? [] : nestedStrings(result.meta)];
|
|
167
|
-
const prepared = await prepareScan([...persisted, ...visible], policy);
|
|
205
|
+
const prepared = await prepareScan([...persisted, ...visible, ...messageStrings(attached), ...messageStrings(deferred)], policy);
|
|
168
206
|
const dirty = (strings) => strings.some(text => prepared.scan(text).length > 0);
|
|
207
|
+
const redactedContexts = redactUserMessages(attached, prepared.scan, hasher, '/additionalContexts');
|
|
208
|
+
// A clean set is passed on as the array it arrived as, so a downstream
|
|
209
|
+
// listener's own messages are not rebuilt for nothing.
|
|
210
|
+
const contexts = decision.additionalContexts === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { additionalContexts: redactedContexts.changed ? redactedContexts.messages : decision.additionalContexts };
|
|
213
|
+
/** Withhold the result, keeping the redacted contexts the decision brought. */
|
|
214
|
+
const withhold = (spans, truncated) => ({
|
|
215
|
+
decision: { kind: 'block', feedback: withheldFeedback(spans), ...contexts },
|
|
216
|
+
spans: [...spans, ...redactedContexts.spans],
|
|
217
|
+
truncatedScan: truncated,
|
|
218
|
+
indicators: prepared.indicators,
|
|
219
|
+
});
|
|
220
|
+
// Deferred contexts ride every accept arm untouched, so a dirty one settles
|
|
221
|
+
// the decision before any arm is considered.
|
|
222
|
+
const deferredSpans = redactUserMessages(deferred, prepared.scan, hasher, '/result/additionalContexts').spans;
|
|
223
|
+
if (deferredSpans.length > 0)
|
|
224
|
+
return withhold(deferredSpans, prepared.truncated);
|
|
169
225
|
if (value !== undefined && dirty(nestedStrings(value))) {
|
|
170
226
|
const redacted = redactJson(value, prepared.scan, hasher);
|
|
171
227
|
if (redactionBreaksSchema(outputSchema, value, redacted.value)) {
|
|
172
|
-
return
|
|
173
|
-
decision: { kind: 'block', feedback: withheldFeedback(redacted.spans) },
|
|
174
|
-
spans: redacted.spans,
|
|
175
|
-
truncatedScan: prepared.truncated,
|
|
176
|
-
indicators: prepared.indicators,
|
|
177
|
-
};
|
|
228
|
+
return withhold(redacted.spans, prepared.truncated);
|
|
178
229
|
}
|
|
179
230
|
const remaining = nestedStrings(redacted.value);
|
|
180
231
|
const residual = await prepareScan(remaining, policy);
|
|
181
232
|
if (remaining.some(text => residual.scan(text).length > 0)) {
|
|
182
|
-
return
|
|
183
|
-
decision: { kind: 'block', feedback: withheldFeedback(redacted.spans) },
|
|
184
|
-
spans: redacted.spans,
|
|
185
|
-
truncatedScan: prepared.truncated || residual.truncated,
|
|
186
|
-
indicators: prepared.indicators,
|
|
187
|
-
};
|
|
233
|
+
return withhold(redacted.spans, prepared.truncated || residual.truncated);
|
|
188
234
|
}
|
|
189
235
|
return {
|
|
190
236
|
decision: { kind: 'accept', value: redacted.value, ...contexts },
|
|
191
|
-
spans: redacted.spans,
|
|
237
|
+
spans: [...redacted.spans, ...redactedContexts.spans],
|
|
192
238
|
truncatedScan: prepared.truncated,
|
|
193
239
|
indicators: prepared.indicators,
|
|
194
240
|
};
|
|
@@ -196,22 +242,21 @@ export async function redactDecision(decision, result, policy, hasher, outputSch
|
|
|
196
242
|
// The value is clean, so the durable result is clean unless `meta` — which
|
|
197
243
|
// no accept arm can rewrite — carries something of its own.
|
|
198
244
|
if (result.meta !== undefined && dirty(nestedStrings(result.meta))) {
|
|
199
|
-
|
|
200
|
-
return {
|
|
201
|
-
decision: { kind: 'block', feedback: withheldFeedback(spans) },
|
|
202
|
-
spans,
|
|
203
|
-
truncatedScan: prepared.truncated,
|
|
204
|
-
indicators: prepared.indicators,
|
|
205
|
-
};
|
|
245
|
+
return withhold(redactJson(result.meta, prepared.scan, hasher).spans, prepared.truncated);
|
|
206
246
|
}
|
|
207
247
|
const blocks = replacedContent ?? result.content;
|
|
208
248
|
const redacted = redactContent(blocks, prepared.scan, hasher);
|
|
209
249
|
if (!redacted.changed) {
|
|
210
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
decision: redactedContexts.changed ? { ...decision, ...contexts } : decision,
|
|
252
|
+
spans: redactedContexts.spans,
|
|
253
|
+
truncatedScan: prepared.truncated,
|
|
254
|
+
indicators: prepared.indicators,
|
|
255
|
+
};
|
|
211
256
|
}
|
|
212
257
|
return {
|
|
213
258
|
decision: { kind: 'accept', content: redacted.content, ...contexts },
|
|
214
|
-
spans: redacted.spans,
|
|
259
|
+
spans: [...redacted.spans, ...redactedContexts.spans],
|
|
215
260
|
truncatedScan: prepared.truncated,
|
|
216
261
|
indicators: prepared.indicators,
|
|
217
262
|
};
|
package/lib/sink.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `callId`, and a producer-minted `decisionId`.
|
|
15
15
|
* @module dsh-dlp/sink
|
|
16
16
|
*/
|
|
17
|
-
import { appendFileSync } from 'node:fs';
|
|
17
|
+
import { appendFileSync, chmodSync } from 'node:fs';
|
|
18
18
|
import { randomUUID } from 'node:crypto';
|
|
19
19
|
import { stripControlSequences } from "./detectors.js";
|
|
20
20
|
/**
|
|
@@ -26,6 +26,15 @@ export function newDecisionId() {
|
|
|
26
26
|
}
|
|
27
27
|
/** Payload version carried inside every record this plugin writes. */
|
|
28
28
|
export const RECORD_VERSION = 1;
|
|
29
|
+
/**
|
|
30
|
+
* Mode the sink file is kept at.
|
|
31
|
+
*
|
|
32
|
+
* The records hold rule ids, keyed hashes, tool names and call identity — no
|
|
33
|
+
* matched value ever reaches them — but they are the evidence that a decision
|
|
34
|
+
* happened, so they are readable by the operator's group and never by the
|
|
35
|
+
* world. This is the mode the sibling packages keep their spools at.
|
|
36
|
+
*/
|
|
37
|
+
export const AUDIT_MODE = 0o640;
|
|
29
38
|
/**
|
|
30
39
|
* One record with every string cleaned of terminal control sequences.
|
|
31
40
|
*
|
|
@@ -68,12 +77,20 @@ export class AuditSink {
|
|
|
68
77
|
* evidence, not enforcement, and letting a full disk turn every tool call
|
|
69
78
|
* into a denial trades a confidentiality control for an availability
|
|
70
79
|
* outage. A guard that throws would also skip `tools/post-execute` and so
|
|
71
|
-
* disable redaction for that call.
|
|
80
|
+
* disable redaction for that call. A failure to hold
|
|
81
|
+
* {@link AUDIT_MODE} is reported on the same terms: the record is written
|
|
82
|
+
* either way, and an operator who cannot see the mode cannot know who else
|
|
83
|
+
* can read the file.
|
|
72
84
|
* @param record - the decision to record.
|
|
73
85
|
*/
|
|
74
86
|
write(record) {
|
|
75
87
|
try {
|
|
76
|
-
appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n
|
|
88
|
+
appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`, { mode: AUDIT_MODE });
|
|
89
|
+
// `appendFileSync`'s `mode` applies only when the call creates the file,
|
|
90
|
+
// and even then the umask masks it, so the mode is forced afterwards.
|
|
91
|
+
// Forcing it on every append also takes back a loosening applied to an
|
|
92
|
+
// existing sink.
|
|
93
|
+
chmodSync(this.#path, AUDIT_MODE);
|
|
77
94
|
}
|
|
78
95
|
catch (error) {
|
|
79
96
|
this.#onFailure(error);
|
package/lib/telemetry.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* @module dsh-dlp/telemetry
|
|
27
27
|
*/
|
|
28
28
|
import { scanSync } from "./detectors.js";
|
|
29
|
-
import { placeholderFor, redactJson, redactText } from "./redaction.js";
|
|
29
|
+
import { placeholderFor, redactJson, redactText, } from "./redaction.js";
|
|
30
30
|
/**
|
|
31
31
|
* What to tell the operator when the redaction seam will never dispatch.
|
|
32
32
|
*
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an `ask` this plugin returns can still reach a human.
|
|
3
|
+
*
|
|
4
|
+
* The `ask` tier is documented as a prompt and never a block: its rules have a
|
|
5
|
+
* real false-positive rate, so a developer who is asked about `CLAUDE.md` or
|
|
6
|
+
* `.github/workflows/**` says yes and carries on. The tool registry resolves an
|
|
7
|
+
* `ask` through `ctx.get('approval')`, and three states of that seam turn the
|
|
8
|
+
* prompt into a denial nobody ever saw:
|
|
9
|
+
*
|
|
10
|
+
* 1. **No service composed.** `ToolRegistry.serviceAsk` keeps the historical
|
|
11
|
+
* degrade to `deny` when `ctx.get('approval')` is `undefined`.
|
|
12
|
+
* 2. **The policy in force is `'never'`.** `ApprovalService.decide` resolves
|
|
13
|
+
* `'rejected'` before any dispatch — its own JSDoc calls this "never prompt
|
|
14
|
+
* anyone". The shipped `dsh-base` bundle selects it for every install under
|
|
15
|
+
* `DSH_PERMISSION_MODE=danger-full-access`, which is the unattended posture.
|
|
16
|
+
* 3. **Nothing composed on `approval/request`.** The waterfall falls through to
|
|
17
|
+
* the fail-closed `'unavailable'`, which the registry maps to `deny`. The
|
|
18
|
+
* shipped `dsh-headless` bundle composes no answerer, so this is the state
|
|
19
|
+
* of a stock headless install under every other permission mode.
|
|
20
|
+
*
|
|
21
|
+
* In all three the call is stopped with no human involved, which is exactly
|
|
22
|
+
* what this tier was designed not to do. Each is decided here so the caller can
|
|
23
|
+
* abstain instead — the same thing it already did for (1) alone.
|
|
24
|
+
*
|
|
25
|
+
* **Every check reports "reachable" when it cannot tell.** Abstaining removes a
|
|
26
|
+
* prompt, so an unreadable service, a missing session, or a `ctx.get` that
|
|
27
|
+
* returned something other than the service read here all keep the ask. Only a
|
|
28
|
+
* positive reading of one of the three states above abstains.
|
|
29
|
+
* @module dsh-dlp/approval-reach
|
|
30
|
+
*/
|
|
31
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
32
|
+
import type { Session } from '@deepseek-ai/dsh-session';
|
|
33
|
+
/** Which of the three states stopped an ask from reaching a human. */
|
|
34
|
+
export type AskUnreachable =
|
|
35
|
+
/** Nothing is composed on `ctx.approval`. */
|
|
36
|
+
'no-service'
|
|
37
|
+
/** The policy in force for this session is `'never'`. */
|
|
38
|
+
| 'policy-never'
|
|
39
|
+
/** Nothing is composed on the `approval/request` waterfall. */
|
|
40
|
+
| 'no-answerer';
|
|
41
|
+
/** Whether an ask can reach a human, and when it cannot, which state stopped it. */
|
|
42
|
+
export type AskReach = {
|
|
43
|
+
readonly kind: 'reachable';
|
|
44
|
+
} | {
|
|
45
|
+
readonly kind: 'unreachable';
|
|
46
|
+
readonly cause: AskUnreachable;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Decide whether an ask would reach a human, at the moment of the decision.
|
|
50
|
+
*
|
|
51
|
+
* Evaluated per call rather than at mount: a session switches policy mid-run
|
|
52
|
+
* through `approval/policy`, the override is per session, and an answerer can
|
|
53
|
+
* be composed or disposed while the harness runs. A mount-time answer would be
|
|
54
|
+
* a cached guess at all three.
|
|
55
|
+
* @param ctx - the plugin's context, for the service and the event bus.
|
|
56
|
+
* @param session - the calling agent's session, or `undefined` when the call has no agent.
|
|
57
|
+
* @returns whether to ask, or which state stopped the ask from reaching anyone.
|
|
58
|
+
*/
|
|
59
|
+
export declare function askReach(ctx: Context, session: Session | undefined): AskReach;
|
|
60
|
+
/**
|
|
61
|
+
* What to tell the operator when the ask tier has nowhere to ask.
|
|
62
|
+
*
|
|
63
|
+
* Each line names the state, what to change to get the prompt back, and the
|
|
64
|
+
* consequence. Reported on `process.stderr` as well as `ctx.logger` for the
|
|
65
|
+
* reason ADR §7 records: the logger's default exporter is an in-memory ring
|
|
66
|
+
* buffer and no shipped bundle mounts a console exporter.
|
|
67
|
+
* @param cause - the state {@link askReach} read.
|
|
68
|
+
* @returns the whole line to report.
|
|
69
|
+
*/
|
|
70
|
+
export declare function approvalSeamNotice(cause: AskUnreachable): string;
|
|
71
|
+
//# sourceMappingURL=approval-reach.d.ts.map
|
package/lib/types/cli.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface ReportRecord {
|
|
|
23
23
|
readonly ruleIds: readonly string[];
|
|
24
24
|
/** Invisible-character runs by rule id. */
|
|
25
25
|
readonly unicode: Readonly<Record<string, number>>;
|
|
26
|
+
/**
|
|
27
|
+
* Which state left the ask tier with nowhere to ask, on the one kind that
|
|
28
|
+
* records it. The abstention allowed a call the tier would have asked
|
|
29
|
+
* about, so this is the field an operator changes to get the prompt back.
|
|
30
|
+
*/
|
|
31
|
+
readonly askUnreachable?: string;
|
|
26
32
|
}
|
|
27
33
|
/**
|
|
28
34
|
* Parse one JSONL line into the fields this command reports on.
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
* config paths and for calls carrying an argument that switches their own
|
|
14
14
|
* confirmation off. Deliberately here rather than on the floor: its rules
|
|
15
15
|
* have a real false-positive rate and the floor cannot ask. Neutralizable,
|
|
16
|
-
* and it abstains entirely
|
|
16
|
+
* and it abstains entirely wherever the approval seam cannot prompt anyone.
|
|
17
17
|
* 3. `tools/post-execute` — result redaction, applied before the `tool/result`
|
|
18
18
|
* session event is appended, so the durable log records the redacted copy;
|
|
19
19
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
20
|
+
* Prepended, so it redacts what the rest of the waterfall returned; a
|
|
21
|
+
* listener registering later with the same option still runs ahead of it.
|
|
20
22
|
* 4. `session-telemetry/record` — fail-closed redaction of exported telemetry,
|
|
21
23
|
* reaching tier 1 only because the waterfall is synchronous.
|
|
22
24
|
* 5. `llm/stream` — neutralising remote markdown image destinations in
|
package/lib/types/redaction.d.ts
CHANGED
|
@@ -14,9 +14,22 @@
|
|
|
14
14
|
* installation key, useless to anyone without the key.
|
|
15
15
|
* @module dsh-dlp/redaction
|
|
16
16
|
*/
|
|
17
|
-
import type { ContentBlock } from '@deepseek-ai/dsh-llm';
|
|
18
|
-
import type { JsonValue } from '@deepseek-ai/dsh-session';
|
|
17
|
+
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm';
|
|
19
18
|
import { type Detection, type Severity } from './detectors.ts';
|
|
19
|
+
/**
|
|
20
|
+
* A value that round-trips through JSON without loss.
|
|
21
|
+
*
|
|
22
|
+
* Declared here rather than imported. Upstream moved this alias out of
|
|
23
|
+
* `@deepseek-ai/dsh-session` and into `@deepseek-ai/dsh-util-values` in
|
|
24
|
+
* `0.1.2-alpha.2`; the new package does not exist in any release the peer
|
|
25
|
+
* ranges also admit, and a TypeScript import cannot name two homes, so either
|
|
26
|
+
* import breaks half the supported range. The alias is structural and carries
|
|
27
|
+
* no runtime, so a local copy is assignable in both directions wherever it
|
|
28
|
+
* meets upstream's — and nothing outside this package is handed one.
|
|
29
|
+
*/
|
|
30
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
31
|
+
[key: string]: JsonValue;
|
|
32
|
+
};
|
|
20
33
|
/** One replaced region, described without disclosing what it held. */
|
|
21
34
|
export interface RedactedSpan {
|
|
22
35
|
/** Rule that justified the replacement; the strictest one when spans merged. */
|
|
@@ -83,9 +96,6 @@ export declare function redactText(text: string, detections: readonly Detection[
|
|
|
83
96
|
export declare function nestedStrings(value: unknown): string[];
|
|
84
97
|
/**
|
|
85
98
|
* Redact every string inside a JSON value, at any depth.
|
|
86
|
-
*
|
|
87
|
-
* Object *keys* are left alone: a key is structure, not payload, and renaming
|
|
88
|
-
* one would break the owning tool's `output.schema` on re-validation.
|
|
89
99
|
* @param value - the structure to redact.
|
|
90
100
|
* @param scan - synchronous detector applied to each string.
|
|
91
101
|
* @param hasher - mints each span's keyed hash.
|
|
@@ -103,11 +113,37 @@ export declare function redactJson(value: JsonValue, scan: (text: string) => rea
|
|
|
103
113
|
* @param blocks - the content blocks to redact.
|
|
104
114
|
* @param scan - synchronous detector applied to each block's text.
|
|
105
115
|
* @param hasher - mints each span's keyed hash.
|
|
116
|
+
* @param pathPrefix - JSON pointer the recorded pointers hang off, for blocks that are not the decision's own.
|
|
106
117
|
* @returns the redacted blocks, the spans replaced, and whether anything changed.
|
|
107
118
|
*/
|
|
108
|
-
export declare function redactContent(blocks: readonly ContentBlock[], scan: (text: string) => readonly Detection[], hasher: SpanHasher): {
|
|
119
|
+
export declare function redactContent(blocks: readonly ContentBlock[], scan: (text: string) => readonly Detection[], hasher: SpanHasher, pathPrefix?: string): {
|
|
109
120
|
content: ContentBlock[];
|
|
110
121
|
spans: readonly RedactedSpan[];
|
|
111
122
|
changed: boolean;
|
|
112
123
|
};
|
|
124
|
+
/**
|
|
125
|
+
* Redact the text of the `UserMessage`s a tool decision carries.
|
|
126
|
+
*
|
|
127
|
+
* A message's payload is its model-facing `content` blocks and whatever text
|
|
128
|
+
* its `source` records: a `snapshot` source repeats the block text in
|
|
129
|
+
* `sections[].text`, a `notice` source repeats its opening in `summary`, and
|
|
130
|
+
* `MessageSourceMap` is merge-extensible, so a plugin's own source kind may
|
|
131
|
+
* carry text of its own. All of it is appended to the session log with the
|
|
132
|
+
* message, so redacting the blocks alone would leave a copy behind. Both
|
|
133
|
+
* halves are walked with the same scan and the same hasher, and identical text
|
|
134
|
+
* yields an identical placeholder, so the copies stay in step.
|
|
135
|
+
*
|
|
136
|
+
* `id` and `role` are left alone: the inbox addresses a message by its id.
|
|
137
|
+
* A message with nothing to replace is returned as the object it arrived as.
|
|
138
|
+
* @param messages - the contexts to redact, in the order they are attached.
|
|
139
|
+
* @param scan - synchronous detector applied to each string.
|
|
140
|
+
* @param hasher - mints each span's keyed hash.
|
|
141
|
+
* @param pathPrefix - JSON pointer the recorded pointers hang off.
|
|
142
|
+
* @returns the redacted messages, the spans replaced, and whether anything changed.
|
|
143
|
+
*/
|
|
144
|
+
export declare function redactUserMessages(messages: readonly UserMessage[], scan: (text: string) => readonly Detection[], hasher: SpanHasher, pathPrefix: string): {
|
|
145
|
+
messages: UserMessage[];
|
|
146
|
+
spans: readonly RedactedSpan[];
|
|
147
|
+
changed: boolean;
|
|
148
|
+
};
|
|
113
149
|
//# sourceMappingURL=redaction.d.ts.map
|
package/lib/types/results.d.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Both are best-effort by construction. A `tools/pre-execute` listener
|
|
6
6
|
* registered ahead of ours can return without calling `next()` and neutralize
|
|
7
|
-
* the breadth tier
|
|
8
|
-
*
|
|
7
|
+
* the breadth tier. Result redaction registers with `{ prepend: true }`, so it
|
|
8
|
+
* redacts whatever the rest of the waterfall settled on rather than having its
|
|
9
|
+
* own decision replaced afterwards — but `prepend` unshifts, so a listener
|
|
10
|
+
* that registers later with the same option still lands ahead of it. Only
|
|
11
|
+
* `ctx.tools.guard()` is order-independent, and it cannot rewrite a result.
|
|
9
12
|
* What these seams buy is breadth: they can await, so `@secretlint/core`'s
|
|
10
13
|
* whole rule set applies here and not in the guard.
|
|
11
14
|
* @module dsh-dlp/results
|
|
@@ -40,8 +43,23 @@ export interface ResultRedaction {
|
|
|
40
43
|
* persisted surfaces are already clean — a failed result, which has no
|
|
41
44
|
* value, or a success whose secret exists only in the rendered content.
|
|
42
45
|
* - `block` is the fallback when neither works: a failed result whose `meta`
|
|
43
|
-
* carries a secret,
|
|
44
|
-
* Blocking replaces the whole result, which
|
|
46
|
+
* carries a secret, a value that still scans dirty after redaction, or a
|
|
47
|
+
* context the tool body deferred. Blocking replaces the whole result, which
|
|
48
|
+
* is the only way to drop either.
|
|
49
|
+
*
|
|
50
|
+
* `additionalContexts` reach both the model and the log — the loop hands each
|
|
51
|
+
* one to the inbox, which appends an `agent/inbox/spliced` event carrying the
|
|
52
|
+
* whole message — and they arrive from two places that are not equally
|
|
53
|
+
* reachable:
|
|
54
|
+
*
|
|
55
|
+
* - the ones this decision carries, which the returned decision owns, so they
|
|
56
|
+
* are redacted in place. Rewriting them is a rewrite of another listener's
|
|
57
|
+
* data, which is the same thing the value arm already does to a downstream
|
|
58
|
+
* `accept{content}`, and it costs a placeholder rather than a lost result;
|
|
59
|
+
* - the ones the tool body deferred, which the registry concatenates ahead of
|
|
60
|
+
* the decision's own on *every* accept arm. No accept can rewrite or drop
|
|
61
|
+
* them, so a dirty one is withheld. That is `meta`'s case exactly, and it is
|
|
62
|
+
* the one place scanning contexts can cost a successful result.
|
|
45
63
|
*
|
|
46
64
|
* Replacing the value is re-validated by the registry against the tool's
|
|
47
65
|
* `output.schema`, and a schema that pins the redacted string rejects it. That
|
package/lib/types/sink.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* `callId`, and a producer-minted `decisionId`.
|
|
15
15
|
* @module dsh-dlp/sink
|
|
16
16
|
*/
|
|
17
|
+
import type { AskUnreachable } from './approval-reach.ts';
|
|
17
18
|
import type { RedactedSpan } from './redaction.ts';
|
|
18
19
|
declare const decisionIdBrand: unique symbol;
|
|
19
20
|
/** Producer-minted id correlating one decision across records. */
|
|
@@ -27,8 +28,17 @@ export type DecisionId = string & {
|
|
|
27
28
|
export declare function newDecisionId(): DecisionId;
|
|
28
29
|
/** Payload version carried inside every record this plugin writes. */
|
|
29
30
|
export declare const RECORD_VERSION = 1;
|
|
31
|
+
/**
|
|
32
|
+
* Mode the sink file is kept at.
|
|
33
|
+
*
|
|
34
|
+
* The records hold rule ids, keyed hashes, tool names and call identity — no
|
|
35
|
+
* matched value ever reaches them — but they are the evidence that a decision
|
|
36
|
+
* happened, so they are readable by the operator's group and never by the
|
|
37
|
+
* world. This is the mode the sibling packages keep their spools at.
|
|
38
|
+
*/
|
|
39
|
+
export declare const AUDIT_MODE = 416;
|
|
30
40
|
/** What produced one audit record. */
|
|
31
|
-
export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | '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' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
|
|
32
42
|
/** One durable record. Never carries matched secret text. */
|
|
33
43
|
export interface AuditRecord {
|
|
34
44
|
readonly v: number;
|
|
@@ -66,6 +76,13 @@ export interface AuditRecord {
|
|
|
66
76
|
* behaviour-changing file, not that any part of it matched a secret.
|
|
67
77
|
*/
|
|
68
78
|
readonly ruleId?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Which state left the ask tier with nowhere to ask, for a
|
|
81
|
+
* `pre-execute-ask-abstained`. An abstention allowed a call the tier would
|
|
82
|
+
* otherwise have asked about, so the state that caused it is the field an
|
|
83
|
+
* operator needs to change to get the prompt back.
|
|
84
|
+
*/
|
|
85
|
+
readonly askUnreachable?: AskUnreachable;
|
|
69
86
|
/** Telemetry record channel, for `telemetry-redaction`. */
|
|
70
87
|
readonly channel?: string;
|
|
71
88
|
/** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
|
|
@@ -95,7 +112,10 @@ export declare class AuditSink {
|
|
|
95
112
|
* evidence, not enforcement, and letting a full disk turn every tool call
|
|
96
113
|
* into a denial trades a confidentiality control for an availability
|
|
97
114
|
* outage. A guard that throws would also skip `tools/post-execute` and so
|
|
98
|
-
* disable redaction for that call.
|
|
115
|
+
* disable redaction for that call. A failure to hold
|
|
116
|
+
* {@link AUDIT_MODE} is reported on the same terms: the record is written
|
|
117
|
+
* either way, and an operator who cannot see the mode cannot know who else
|
|
118
|
+
* can read the file.
|
|
99
119
|
* @param record - the decision to record.
|
|
100
120
|
*/
|
|
101
121
|
write(record: AuditRecord): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dlp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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>",
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@deepseek-ai/cordis": "4.0.1",
|
|
54
|
-
"@deepseek-ai/dsh-llm": "
|
|
55
|
-
"@deepseek-ai/dsh-session": "
|
|
56
|
-
"@deepseek-ai/dsh-session-telemetry": "
|
|
57
|
-
"@deepseek-ai/dsh-tools": "
|
|
53
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
54
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
|
|
55
|
+
"@deepseek-ai/dsh-session": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
|
|
56
|
+
"@deepseek-ai/dsh-session-telemetry": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0",
|
|
57
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@deepseek-ai/schemastery": "3.18.1",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"js-yaml": "^4.1.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@deepseek-ai/cordis": "4.0.
|
|
66
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
67
67
|
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
|
68
68
|
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
69
69
|
"@deepseek-ai/dsh-llm-mock-server": "0.1.0-rc.6",
|