dsh-dlp 0.5.0 → 0.7.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 +20 -9
- package/SECURITY.md +8 -6
- package/lib/approval-reach.js +144 -0
- package/lib/approvals.js +24 -10
- package/lib/cli.js +4 -4
- package/lib/config-writes.js +4 -2
- package/lib/index.js +32 -19
- package/lib/paths.js +7 -3
- package/lib/types/approval-reach.d.ts +71 -0
- package/lib/types/approvals.d.ts +13 -5
- package/lib/types/cli.d.ts +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/sink.d.ts +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,9 +26,14 @@ 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
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
32
|
+
8. **Writes an audit record for every decision.** A redaction or denial names the rule, its
|
|
33
|
+
version, the offsets and a keyed hash; the three kinds with no matched region to describe —
|
|
34
|
+
an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
|
|
35
|
+
the destination hostname instead. Never the secret, never the path or command that matched.
|
|
36
|
+
`dsh-dlp report` reads it back.
|
|
32
37
|
|
|
33
38
|
## What this is not
|
|
34
39
|
|
|
@@ -44,9 +49,12 @@ Three limits worth knowing before you rely on it:
|
|
|
44
49
|
- **Only the guard floor is unconditional.** Every other seam can be neutralised by a listener
|
|
45
50
|
registered ahead of ours. `ctx.tools.guard()` is order-independent only because it has no allow
|
|
46
51
|
arm.
|
|
47
|
-
- **The shell-command arm is advisory pattern-matching.** It
|
|
48
|
-
|
|
49
|
-
`python3 -c`
|
|
52
|
+
- **The shell-command arm is advisory pattern-matching.** It tests the whole command line and
|
|
53
|
+
each of its tokens, so a credential path left *spelled* in the command is caught whatever
|
|
54
|
+
program would open it: `python3 -c "open('~/.ssh/id_rsa')"` is denied. Changing the spelling
|
|
55
|
+
defeats it — one glob character, quote-splitting, `find -exec`, a substitution that assembles
|
|
56
|
+
the path from pieces, a base64 round-trip, each verified. **Do not count this arm as a
|
|
57
|
+
control.**
|
|
50
58
|
- **Detection is pattern-based.** No entropy rule (measured, not assumed: at a false-positive-free
|
|
51
59
|
threshold the miss rate is 100% below 22 characters). Encoded forms pass. A homoglyph defeats
|
|
52
60
|
every rule in this package.
|
|
@@ -108,11 +116,14 @@ switch a pass on. Any downgrade makes the whole file invalid.
|
|
|
108
116
|
```sh
|
|
109
117
|
dsh-dlp report # everything in the audit sink
|
|
110
118
|
dsh-dlp report --since 24h
|
|
111
|
-
dsh-dlp report --
|
|
119
|
+
dsh-dlp report --session <id>
|
|
120
|
+
dsh-dlp report --would-have # everything except the denials
|
|
112
121
|
```
|
|
113
122
|
|
|
114
|
-
|
|
115
|
-
value.
|
|
123
|
+
A redaction or denial record carries a rule id, rule version, span offsets and a keyed hash —
|
|
124
|
+
never the matched value. An ask carries its rule id, a rewritten call the names of the fields
|
|
125
|
+
that changed, and a neutralised remote image the destination hostname in the clear; none of
|
|
126
|
+
those has a matched region to hash.
|
|
116
127
|
|
|
117
128
|
[Audit record format →](https://charlotten7.github.io/dsh-dlp/audit.html)
|
|
118
129
|
|
package/SECURITY.md
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
| Version | Supported |
|
|
6
6
|
|---|---|
|
|
7
|
-
| 0.
|
|
8
|
-
| < 0.
|
|
7
|
+
| 0.5.x | yes |
|
|
8
|
+
| < 0.5 | no |
|
|
9
9
|
|
|
10
|
-
Only the latest published `0.
|
|
10
|
+
Only the latest published `0.5.x` receives fixes. There is no long-term-support branch while
|
|
11
11
|
the package is pre-1.0: each minor supersedes the one before it, and a fix ships as the next
|
|
12
|
-
`0.
|
|
12
|
+
`0.5.x` patch or, if the minor has already moved on, as the next minor.
|
|
13
13
|
|
|
14
14
|
## Reporting a vulnerability
|
|
15
15
|
|
|
@@ -32,8 +32,10 @@ This plugin is **not a containment boundary**. It runs in-process at the agent's
|
|
|
32
32
|
anything the agent can execute can read the same files the guard denies. The following are
|
|
33
33
|
documented limits, not vulnerabilities — they are described in README.md:
|
|
34
34
|
|
|
35
|
-
- shell-command obfuscation defeating the `bash` path arm
|
|
36
|
-
|
|
35
|
+
- shell-command obfuscation defeating the `bash` path arm — anything that stops the path being
|
|
36
|
+
spelled in the command line: globbing, quote-splitting, `find -exec`, assembling the path
|
|
37
|
+
from pieces, a base64 round-trip. A command that spells the path is caught whatever program
|
|
38
|
+
it runs, so that is a gap worth reporting;
|
|
37
39
|
- encoded or split secrets passing both detection tiers;
|
|
38
40
|
- a secret with no recognisable structure going undetected;
|
|
39
41
|
- a secret reaching the provider because it was already in the conversation.
|
|
@@ -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/approvals.js
CHANGED
|
@@ -30,7 +30,13 @@ import { isReadOnlyTool } from "./paths.js";
|
|
|
30
30
|
* for the prompt.
|
|
31
31
|
*/
|
|
32
32
|
const SUPPRESSING_TRUE = /^(?:true|yes|on|1)$/;
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Values of an approval-mode argument that name the absence of a prompt.
|
|
35
|
+
*
|
|
36
|
+
* One spelling per mode, because the value is normalized the same way the key
|
|
37
|
+
* is: `full-auto` — which is what Codex writes — `full_auto` and `fullauto`
|
|
38
|
+
* are one entry rather than three.
|
|
39
|
+
*/
|
|
34
40
|
const SUPPRESSING_MODE = /^(?:auto|autoapprove|autoedit|never|none|bypass|fullauto|yolo)$/;
|
|
35
41
|
/**
|
|
36
42
|
* Arguments that turn off the human confirmation for the call carrying them.
|
|
@@ -51,7 +57,7 @@ export const APPROVAL_SUPPRESSION_RULES = [
|
|
|
51
57
|
// CVE-2026-56075.
|
|
52
58
|
{
|
|
53
59
|
id: 'dsh-dlp/approval-mode-auto',
|
|
54
|
-
version:
|
|
60
|
+
version: 2,
|
|
55
61
|
condition: { key: /^approval(?:mode|policy|setting)$/, value: SUPPRESSING_MODE },
|
|
56
62
|
effect: 'an approval mode that approves on the model\'s behalf instead of asking',
|
|
57
63
|
},
|
|
@@ -66,25 +72,33 @@ export const APPROVAL_SUPPRESSION_RULES = [
|
|
|
66
72
|
},
|
|
67
73
|
];
|
|
68
74
|
/**
|
|
69
|
-
* The spelling one
|
|
75
|
+
* The spelling one key or value is matched under: lowercase, with the
|
|
70
76
|
* separators that distinguish `non_interactive`, `nonInteractive` and
|
|
71
|
-
* `non-interactive` removed.
|
|
72
|
-
*
|
|
77
|
+
* `non-interactive` — and `full-auto` from `full_auto` — removed.
|
|
78
|
+
*
|
|
79
|
+
* Values take the same normalization as keys, and that is the whole reason
|
|
80
|
+
* `approval_mode: full-auto`, which is the spelling Codex writes, reaches the
|
|
81
|
+
* table: enumerating the separator variants one at a time only ever covers the
|
|
82
|
+
* spellings someone already thought of. Every value the rules name is a single
|
|
83
|
+
* word with no legitimate hyphenated or dotted form, so folding the separators
|
|
84
|
+
* away cannot pull an ordinary value in — `on-demand` and `ask-every-time`
|
|
85
|
+
* stay outside the table.
|
|
86
|
+
* @param text - a key as the tool declared it, or a scalar value it carried.
|
|
73
87
|
* @returns the normalized spelling.
|
|
74
88
|
*/
|
|
75
|
-
export function
|
|
76
|
-
return
|
|
89
|
+
export function normalizeArgumentToken(text) {
|
|
90
|
+
return text.toLowerCase().replace(/[_.-]/g, '');
|
|
77
91
|
}
|
|
78
92
|
/**
|
|
79
93
|
* One argument value as a string, for the values a flag can take.
|
|
80
94
|
* @param node - the value under one argument key.
|
|
81
|
-
* @returns the
|
|
95
|
+
* @returns the normalized rendering, or `undefined` for an object or a list.
|
|
82
96
|
*/
|
|
83
97
|
function scalarValue(node) {
|
|
84
98
|
if (typeof node === 'boolean' || typeof node === 'number')
|
|
85
99
|
return String(node);
|
|
86
100
|
if (typeof node === 'string')
|
|
87
|
-
return node.trim()
|
|
101
|
+
return normalizeArgumentToken(node.trim());
|
|
88
102
|
return undefined;
|
|
89
103
|
}
|
|
90
104
|
/** Whether one object carries a key and value the condition describes. */
|
|
@@ -121,7 +135,7 @@ export function matchApprovalSuppression(args, rules = APPROVAL_SUPPRESSION_RULE
|
|
|
121
135
|
for (const [key, value] of Object.entries(node)) {
|
|
122
136
|
const scalar = scalarValue(value);
|
|
123
137
|
if (scalar !== undefined)
|
|
124
|
-
entries.set(
|
|
138
|
+
entries.set(normalizeArgumentToken(key), scalar);
|
|
125
139
|
}
|
|
126
140
|
found = rules.find(rule => satisfies(entries, rule.condition)
|
|
127
141
|
&& (rule.alongside === undefined || satisfies(entries, rule.alongside)));
|
package/lib/cli.js
CHANGED
|
@@ -94,9 +94,9 @@ export const USAGE = [
|
|
|
94
94
|
' --since <when> only decisions at or after an ISO timestamp, or a span back',
|
|
95
95
|
' from now written as 30m, 24h or 7d',
|
|
96
96
|
' --session <id> only decisions from one session',
|
|
97
|
-
' --would-have
|
|
98
|
-
'
|
|
99
|
-
'
|
|
97
|
+
' --would-have leave out the decisions that stopped a call, keeping the',
|
|
98
|
+
' redactions, the invisible-character findings, the asks and',
|
|
99
|
+
' the neutralised images',
|
|
100
100
|
' -h, --help print this text',
|
|
101
101
|
].join('\n');
|
|
102
102
|
/** Milliseconds in one `--since` suffix; {@link parseSince} accepts no other. */
|
|
@@ -250,7 +250,7 @@ export function formatReport(records, unreadable, options) {
|
|
|
250
250
|
if (options.session !== undefined)
|
|
251
251
|
lines.push(` session ${options.session}`);
|
|
252
252
|
if (options.wouldHave)
|
|
253
|
-
lines.push('
|
|
253
|
+
lines.push(' decisions that stopped a call left out');
|
|
254
254
|
if (unreadable > 0)
|
|
255
255
|
lines.push(` ${unreadable} line(s) were not readable as records`);
|
|
256
256
|
if (selected.length === 0)
|
package/lib/config-writes.js
CHANGED
|
@@ -51,9 +51,11 @@ export const CONFIG_WRITE_RULES = [
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
id: 'dsh-dlp/config-agent-hooks',
|
|
54
|
-
version:
|
|
54
|
+
version: 2,
|
|
55
55
|
match: 'path',
|
|
56
|
-
|
|
56
|
+
// `.cursor` is in the sibling settings rule above and was missing here,
|
|
57
|
+
// which left one of the six agent directories' hooks unguarded.
|
|
58
|
+
pattern: /(^|\/)\.(claude|gemini|codex|cursor|windsurf|continue)\/hooks(\/|$)/i,
|
|
57
59
|
effect: 'an agent hook, which runs on a session event without the model asking for it',
|
|
58
60
|
},
|
|
59
61
|
// Copilot reads these without any agent asking it to: VS Code documents
|
package/lib/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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.
|
|
@@ -43,6 +43,7 @@ import { neutralizeImageStream } from "./images.js";
|
|
|
43
43
|
import { ExecutionSnapshots, mutationReason } from "./mutation.js";
|
|
44
44
|
import { evaluateConfigWrite } from "./config-writes.js";
|
|
45
45
|
import { evaluateApprovalSuppression } from "./approvals.js";
|
|
46
|
+
import { approvalSeamNotice, askReach } from "./approval-reach.js";
|
|
46
47
|
import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
|
|
47
48
|
import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
|
|
48
49
|
import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
|
|
@@ -238,25 +239,20 @@ export function apply(ctx, config) {
|
|
|
238
239
|
return verdict.reason;
|
|
239
240
|
}), 'dsh-dlp guard floor');
|
|
240
241
|
/**
|
|
241
|
-
* Report once that the `ask` tier has nowhere to ask.
|
|
242
|
+
* Report once per state that the `ask` tier has nowhere to ask.
|
|
242
243
|
*
|
|
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.
|
|
244
|
+
* Latched per state rather than once overall: the three states have
|
|
245
|
+
* different fixes, a session can move between them mid-run by switching its
|
|
246
|
+
* approval policy, and a single latch would leave the first one reported
|
|
247
|
+
* standing for a different one afterwards. `approval-reach.ts` records why
|
|
248
|
+
* each is a state in which an ask reaches nobody.
|
|
250
249
|
*/
|
|
251
|
-
|
|
252
|
-
const discloseApprovalSeam = () => {
|
|
253
|
-
if (approvalSeamReported)
|
|
250
|
+
const approvalSeamReported = new Set();
|
|
251
|
+
const discloseApprovalSeam = (cause) => {
|
|
252
|
+
if (approvalSeamReported.has(cause))
|
|
254
253
|
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.');
|
|
254
|
+
approvalSeamReported.add(cause);
|
|
255
|
+
notice(ctx, approvalSeamNotice(cause));
|
|
260
256
|
};
|
|
261
257
|
if (policy.configWriteAsk || policy.approvalSuppressionAsk) {
|
|
262
258
|
// Registered ahead of the breadth tier, so a call that is both a config
|
|
@@ -280,8 +276,25 @@ export function apply(ctx, config) {
|
|
|
280
276
|
// and would file the decision as an ask rather than as a guard denial.
|
|
281
277
|
if (safeEvaluateGuard(exec, policy, hasher) !== undefined)
|
|
282
278
|
return decision;
|
|
283
|
-
|
|
284
|
-
|
|
279
|
+
// Asked at decision time, never at mount: the session's policy is a fold
|
|
280
|
+
// over its own log and can change mid-run, and an answerer can be
|
|
281
|
+
// composed or disposed while the harness runs.
|
|
282
|
+
const reach = askReach(ctx, exec.agent?.session);
|
|
283
|
+
if (reach.kind === 'unreachable') {
|
|
284
|
+
discloseApprovalSeam(reach.cause);
|
|
285
|
+
// An abstention allowed a call this tier would have asked about, so it
|
|
286
|
+
// is recorded rather than left silent. Its own kind, not a flag on
|
|
287
|
+
// `pre-execute-ask`: `dsh-dlp report` counts by kind, and an ask that
|
|
288
|
+
// reached nobody must not be counted as a prompt that happened.
|
|
289
|
+
sink.write({
|
|
290
|
+
v: RECORD_VERSION,
|
|
291
|
+
time: new Date().toISOString(),
|
|
292
|
+
kind: 'pre-execute-ask-abstained',
|
|
293
|
+
decisionId: newDecisionId(),
|
|
294
|
+
...identity(exec),
|
|
295
|
+
ruleId: finding.rule.id,
|
|
296
|
+
askUnreachable: reach.cause,
|
|
297
|
+
});
|
|
285
298
|
return decision;
|
|
286
299
|
}
|
|
287
300
|
sink.write({
|
package/lib/paths.js
CHANGED
|
@@ -40,7 +40,9 @@ export const CREDENTIAL_PATH_RULES = [
|
|
|
40
40
|
{ id: 'dsh-dlp/path-pypirc', version: 1, pattern: /(^|\/)\.pypirc$/i },
|
|
41
41
|
{ id: 'dsh-dlp/path-git-credentials', version: 1, pattern: /(^|\/)\.git-credentials$/i },
|
|
42
42
|
{ id: 'dsh-dlp/path-gh-config', version: 1, pattern: /(^|\/)\.config\/gh(\/|$)/i },
|
|
43
|
-
|
|
43
|
+
// `.kube` matches at any depth, like `.aws` and `.azure`: the cached OIDC and
|
|
44
|
+
// exec-plugin tokens sit under `~/.kube/cache/`, not beside the config file.
|
|
45
|
+
{ id: 'dsh-dlp/path-kubeconfig', version: 3, pattern: /(^|\/)(\.kube(\/|$)|kubeconfig[^/]*$)/i },
|
|
44
46
|
{ id: 'dsh-dlp/path-kubernetes-conf', version: 1, pattern: /(^|\/)kubernetes\/[^/]*\.conf$/i },
|
|
45
47
|
{ id: 'dsh-dlp/path-docker-config', version: 2, pattern: /(^|\/)(\.docker\/config\.json|\.dockercfg)$/i },
|
|
46
48
|
{ id: 'dsh-dlp/path-gcloud-credentials', version: 1, pattern: /(^|\/)\.config\/gcloud\/[^/]*credential[^/]*$/i },
|
|
@@ -53,8 +55,10 @@ export const CREDENTIAL_PATH_RULES = [
|
|
|
53
55
|
// token file whatever else the directory holds.
|
|
54
56
|
{ id: 'dsh-dlp/path-agent-auth', version: 1, pattern: /(^|\/)\.?(codex|cursor|composer|windsurf|continue|aider|claude|gemini)\/auth\.json$/i },
|
|
55
57
|
// An MCP manifest carries each server's `env`, which is where its API keys
|
|
56
|
-
// are written.
|
|
57
|
-
|
|
58
|
+
// are written. The directory alternation is the one above: an agent that
|
|
59
|
+
// keeps an `auth.json` keeps its manifest beside it, and Cursor's own
|
|
60
|
+
// directory is spelled without the dot under `~/.config`.
|
|
61
|
+
{ id: 'dsh-dlp/path-agent-mcp-config', version: 2, pattern: /(^|\/)\.?(codex|cursor|composer|windsurf|continue|aider|claude|gemini)\/mcp\.json$/i },
|
|
58
62
|
// Cursor keeps session tokens in a SQLite state database rather than a
|
|
59
63
|
// credential file.
|
|
60
64
|
{ id: 'dsh-dlp/path-editor-state-db', version: 1, pattern: /(^|\/)state\.vscdb(-journal|-wal|-shm)?$/i },
|
|
@@ -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/approvals.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools';
|
|
|
26
26
|
export interface ArgumentCondition {
|
|
27
27
|
/** Matched against the key lowercased with `_`, `-` and `.` removed. */
|
|
28
28
|
readonly key: RegExp;
|
|
29
|
-
/** Matched against the value
|
|
29
|
+
/** Matched against the value under the same normalization as the key. */
|
|
30
30
|
readonly value: RegExp;
|
|
31
31
|
}
|
|
32
32
|
/** One argument, or pair of arguments, that suppresses a confirmation. */
|
|
@@ -54,13 +54,21 @@ export interface ApprovalSuppressionRule {
|
|
|
54
54
|
*/
|
|
55
55
|
export declare const APPROVAL_SUPPRESSION_RULES: readonly ApprovalSuppressionRule[];
|
|
56
56
|
/**
|
|
57
|
-
* The spelling one
|
|
57
|
+
* The spelling one key or value is matched under: lowercase, with the
|
|
58
58
|
* separators that distinguish `non_interactive`, `nonInteractive` and
|
|
59
|
-
* `non-interactive` removed.
|
|
60
|
-
*
|
|
59
|
+
* `non-interactive` — and `full-auto` from `full_auto` — removed.
|
|
60
|
+
*
|
|
61
|
+
* Values take the same normalization as keys, and that is the whole reason
|
|
62
|
+
* `approval_mode: full-auto`, which is the spelling Codex writes, reaches the
|
|
63
|
+
* table: enumerating the separator variants one at a time only ever covers the
|
|
64
|
+
* spellings someone already thought of. Every value the rules name is a single
|
|
65
|
+
* word with no legitimate hyphenated or dotted form, so folding the separators
|
|
66
|
+
* away cannot pull an ordinary value in — `on-demand` and `ask-every-time`
|
|
67
|
+
* stay outside the table.
|
|
68
|
+
* @param text - a key as the tool declared it, or a scalar value it carried.
|
|
61
69
|
* @returns the normalized spelling.
|
|
62
70
|
*/
|
|
63
|
-
export declare function
|
|
71
|
+
export declare function normalizeArgumentToken(text: string): string;
|
|
64
72
|
/**
|
|
65
73
|
* The first rule any object inside the arguments satisfies.
|
|
66
74
|
*
|
package/lib/types/cli.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface ReportOptions {
|
|
|
36
36
|
/** Epoch milliseconds; records before it are left out. */
|
|
37
37
|
readonly since?: number;
|
|
38
38
|
readonly session?: string;
|
|
39
|
-
/**
|
|
39
|
+
/** Leave out the decisions that stopped a call; see {@link DENYING_KINDS}. */
|
|
40
40
|
readonly wouldHave: boolean;
|
|
41
41
|
}
|
|
42
42
|
/** The outcome of reading the command line. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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.
|
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. */
|
|
@@ -28,7 +29,7 @@ export declare function newDecisionId(): DecisionId;
|
|
|
28
29
|
/** Payload version carried inside every record this plugin writes. */
|
|
29
30
|
export declare const RECORD_VERSION = 1;
|
|
30
31
|
/** 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';
|
|
32
|
+
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
33
|
/** One durable record. Never carries matched secret text. */
|
|
33
34
|
export interface AuditRecord {
|
|
34
35
|
readonly v: number;
|
|
@@ -66,6 +67,13 @@ export interface AuditRecord {
|
|
|
66
67
|
* behaviour-changing file, not that any part of it matched a secret.
|
|
67
68
|
*/
|
|
68
69
|
readonly ruleId?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Which state left the ask tier with nowhere to ask, for a
|
|
72
|
+
* `pre-execute-ask-abstained`. An abstention allowed a call the tier would
|
|
73
|
+
* otherwise have asked about, so the state that caused it is the field an
|
|
74
|
+
* operator needs to change to get the prompt back.
|
|
75
|
+
*/
|
|
76
|
+
readonly askUnreachable?: AskUnreachable;
|
|
69
77
|
/** Telemetry record channel, for `telemetry-redaction`. */
|
|
70
78
|
readonly channel?: string;
|
|
71
79
|
/** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dlp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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>",
|