dsh-perm-gate 5.1.2-compat.7 → 5.2.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/CHANGELOG.md +104 -0
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/dsh.plugin.json +1 -1
- package/lib/ask-context.d.ts +207 -0
- package/lib/ask-context.js +504 -0
- package/lib/bridge.d.ts +9 -2
- package/lib/bridge.js +7 -4
- package/lib/classifier.d.ts +8 -14
- package/lib/classifier.js +24 -2
- package/lib/client.js +126 -126
- package/lib/client.js.map +1 -1
- package/lib/command-normalize.d.ts +80 -0
- package/lib/command-normalize.js +246 -0
- package/lib/config.d.ts +92 -83
- package/lib/config.js +65 -41
- package/lib/engine.js +78 -58
- package/lib/evaluate.d.ts +5 -0
- package/lib/evaluate.js +24 -13
- package/lib/events.d.ts +26 -0
- package/lib/events.js +7 -0
- package/lib/grader-rules.d.ts +32 -0
- package/lib/grader-rules.js +48 -0
- package/lib/index.js +29 -0
- package/lib/irreversible.d.ts +54 -0
- package/lib/irreversible.js +376 -0
- package/lib/parsers/shell-cmds.d.ts +9 -0
- package/lib/parsers/shell-cmds.js +37 -3
- package/lib/preset-rules.d.ts +70 -0
- package/lib/preset-rules.js +388 -0
- package/lib/redact.d.ts +55 -0
- package/lib/redact.js +183 -0
- package/lib/risk.d.ts +32 -5
- package/lib/risk.js +44 -40
- package/lib/runtime.d.ts +82 -0
- package/lib/runtime.js +222 -27
- package/lib/tool-names.d.ts +43 -0
- package/lib/tool-names.js +85 -0
- package/package.json +5 -4
package/lib/config.js
CHANGED
|
@@ -150,64 +150,88 @@ export function readRulesFromSettings(scope) {
|
|
|
150
150
|
const value = scope?.get();
|
|
151
151
|
return isRulesConfigured(value) ? value : undefined;
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Feature guard for `Schema…volatile()`.
|
|
155
|
+
*
|
|
156
|
+
* `.volatile()` is not guaranteed to exist on the schemastery copy this plugin
|
|
157
|
+
* resolves at runtime. Every node below used to chain it unguarded **inside the
|
|
158
|
+
* module body**, so a missing method throws while `Config` is being built: the
|
|
159
|
+
* plugin fails to load at all (`engines.dsh` declares support for all 15 host
|
|
160
|
+
* lines, so that is a hard load-failure, not a degradation).
|
|
161
|
+
*
|
|
162
|
+
* Probing per node keeps the plain schema node instead. The read side already
|
|
163
|
+
* tolerates that — `readVolatileValue` / `resolveVolatileConfig` treat a value
|
|
164
|
+
* without `get()` as the frozen snapshot — so every switch still works, and only
|
|
165
|
+
* hot reload of a changed value is lost (it then needs a restart).
|
|
166
|
+
*
|
|
167
|
+
* Do **not** “fix” this by adding `volatile` to schemastery's prototype: the
|
|
168
|
+
* profile uses a hoisted flat layout, so that single copy is shared by every
|
|
169
|
+
* plugin in the profile (14 of them declare schemastery themselves) — a
|
|
170
|
+
* prototype write here would silently change their behaviour too.
|
|
171
|
+
*/
|
|
172
|
+
function vol(node) {
|
|
173
|
+
const maybe = node;
|
|
174
|
+
return typeof maybe.volatile === 'function' ? maybe.volatile() : node;
|
|
175
|
+
}
|
|
153
176
|
export const Config = z.object({
|
|
154
177
|
rulesFile: z.string(),
|
|
155
178
|
dshHome: z.string(),
|
|
156
|
-
defaultAction: z.union(['allow', 'ask', 'deny']).default('ask')
|
|
157
|
-
caseInsensitivePaths: z.boolean().default(true)
|
|
158
|
-
classifierEnabled: z.boolean().default(false)
|
|
159
|
-
classifierEndpoint: z.string()
|
|
160
|
-
classifierModel: z.string().default('deepseek-chat')
|
|
161
|
-
classifierApiKey: z.string()
|
|
162
|
-
riskTimeoutMs: z.number().min(1000).default(20_000)
|
|
163
|
-
riskLearning: z.boolean().default(true)
|
|
164
|
-
riskSediment: z.boolean().default(true)
|
|
165
|
-
classifierSource: z.union(['custom', 'host']).default('custom')
|
|
166
|
-
classifierProvider: z.string()
|
|
167
|
-
riskThreshold: z.number().min(1).max(10).default(1)
|
|
179
|
+
defaultAction: vol(z.union(['allow', 'ask', 'deny']).default('ask')),
|
|
180
|
+
caseInsensitivePaths: vol(z.boolean().default(true)),
|
|
181
|
+
classifierEnabled: vol(z.boolean().default(false)),
|
|
182
|
+
classifierEndpoint: vol(z.string()),
|
|
183
|
+
classifierModel: vol(z.string().default('deepseek-chat')),
|
|
184
|
+
classifierApiKey: vol(z.string()),
|
|
185
|
+
riskTimeoutMs: vol(z.number().min(1000).default(20_000)),
|
|
186
|
+
riskLearning: vol(z.boolean().default(true)),
|
|
187
|
+
riskSediment: vol(z.boolean().default(true)),
|
|
188
|
+
classifierSource: vol(z.union(['custom', 'host']).default('custom')),
|
|
189
|
+
classifierProvider: vol(z.string()),
|
|
190
|
+
riskThreshold: vol(z.number().min(1).max(10).default(1)),
|
|
168
191
|
learningFile: z.string(),
|
|
169
192
|
eventsFile: z.string(),
|
|
170
|
-
grantTtlMs: z.number().min(1).default(5 * 60_000)
|
|
171
|
-
grantMaxUses: z.number().min(1).default(1)
|
|
172
|
-
permissive: z.boolean().default(false)
|
|
193
|
+
grantTtlMs: vol(z.number().min(1).default(5 * 60_000)),
|
|
194
|
+
grantMaxUses: vol(z.number().min(1).default(1)),
|
|
195
|
+
permissive: vol(z.boolean().default(false)),
|
|
173
196
|
gatePresets: z.array(z.string()),
|
|
174
|
-
permissiveStrategies: z.object({
|
|
197
|
+
permissiveStrategies: vol(z.object({
|
|
175
198
|
trustAutoAllow: z.boolean().default(true),
|
|
176
199
|
alwaysConfirm: z.boolean().default(false),
|
|
177
200
|
llmAssist: z.boolean().default(false),
|
|
178
201
|
trustEscalation: z.boolean().default(true),
|
|
179
|
-
})
|
|
180
|
-
allowlist: z.array(z.string())
|
|
181
|
-
denyKeywords: z.array(z.string())
|
|
182
|
-
|
|
183
|
-
|
|
202
|
+
})),
|
|
203
|
+
allowlist: vol(z.array(z.string())),
|
|
204
|
+
denyKeywords: vol(z.array(z.string())),
|
|
205
|
+
presetRulesSeeded: vol(z.boolean().default(false)),
|
|
206
|
+
autoAllowTools: vol(z.array(z.string())),
|
|
207
|
+
sessionSweep: vol(z.boolean().default(true)),
|
|
184
208
|
workspaceStoreFile: z.string(),
|
|
185
209
|
// Rule chain (T1.9)
|
|
186
|
-
searchUp: z.boolean().default(false)
|
|
210
|
+
searchUp: vol(z.boolean().default(false)),
|
|
187
211
|
fallbackPath: z.string(),
|
|
188
|
-
badFilePolicy: z.union(['fail', 'warn']).default('fail')
|
|
189
|
-
maxChainLength: z.number().min(1).max(50).default(10)
|
|
212
|
+
badFilePolicy: vol(z.union(['fail', 'warn']).default('fail')),
|
|
213
|
+
maxChainLength: vol(z.number().min(1).max(50).default(10)),
|
|
190
214
|
// Network (Phase 2)
|
|
191
|
-
networkEnabled: z.boolean().default(false)
|
|
192
|
-
networkMode: z.union(['deny-all', 'whitelist', 'allow-all']).default('whitelist')
|
|
193
|
-
networkUnlisted: z.union(['ask', 'deny']).default('ask')
|
|
194
|
-
networkUnattributed: z.union(['allow', 'deny']).default('allow')
|
|
195
|
-
networkLoopback: z.union(['allow', 'policy']).default('allow')
|
|
196
|
-
networkBind: z.string().default('127.0.0.1')
|
|
197
|
-
networkPort: z.number().min(0).max(65535).default(0)
|
|
198
|
-
networkNoProxy: z.union(['clear', 'preserve']).default('clear')
|
|
199
|
-
networkInjectEnv: z.boolean().default(true)
|
|
200
|
-
networkAskTimeoutMs: z.number().min(1000).max(600_000).default(120_000)
|
|
201
|
-
networkGrantTtlMs: z.number().min(0).max(24 * 60 * 60_000).default(30 * 60_000)
|
|
215
|
+
networkEnabled: vol(z.boolean().default(false)),
|
|
216
|
+
networkMode: vol(z.union(['deny-all', 'whitelist', 'allow-all']).default('whitelist')),
|
|
217
|
+
networkUnlisted: vol(z.union(['ask', 'deny']).default('ask')),
|
|
218
|
+
networkUnattributed: vol(z.union(['allow', 'deny']).default('allow')),
|
|
219
|
+
networkLoopback: vol(z.union(['allow', 'policy']).default('allow')),
|
|
220
|
+
networkBind: vol(z.string().default('127.0.0.1')),
|
|
221
|
+
networkPort: vol(z.number().min(0).max(65535).default(0)),
|
|
222
|
+
networkNoProxy: vol(z.union(['clear', 'preserve']).default('clear')),
|
|
223
|
+
networkInjectEnv: vol(z.boolean().default(true)),
|
|
224
|
+
networkAskTimeoutMs: vol(z.number().min(1000).max(600_000).default(120_000)),
|
|
225
|
+
networkGrantTtlMs: vol(z.number().min(0).max(24 * 60 * 60_000).default(30 * 60_000)),
|
|
202
226
|
// AppContainer sandbox (Windows; goal 2)
|
|
203
|
-
sandboxEnabled: z.boolean().default(false)
|
|
204
|
-
sandboxMode: z.union(['read-only', 'workspace-write']).default('workspace-write')
|
|
205
|
-
sandboxProxy: z.string().default('')
|
|
206
|
-
sandboxLoopback: z.union(['exempt', 'off']).default('exempt')
|
|
227
|
+
sandboxEnabled: vol(z.boolean().default(false)),
|
|
228
|
+
sandboxMode: vol(z.union(['read-only', 'workspace-write']).default('workspace-write')),
|
|
229
|
+
sandboxProxy: vol(z.string().default('')),
|
|
230
|
+
sandboxLoopback: vol(z.union(['exempt', 'off']).default('exempt')),
|
|
207
231
|
// 0.1.7: the rules document lives on this entry as a volatile whole-object
|
|
208
232
|
// field (a second settings namespace is no longer projectable). Unconfigured
|
|
209
233
|
// (bare defaults) -> the rules file remains the source.
|
|
210
|
-
rules: RulesSchema
|
|
234
|
+
rules: vol(RulesSchema),
|
|
211
235
|
// Hot reload (Phase 3)
|
|
212
236
|
watch: z.boolean().default(false),
|
|
213
237
|
watchDebounceMs: z.number().min(50).max(5000).default(300),
|
|
@@ -217,7 +241,7 @@ export const VOLATILE_CONFIG_KEYS = [
|
|
|
217
241
|
'defaultAction', 'caseInsensitivePaths', 'classifierEnabled', 'classifierEndpoint',
|
|
218
242
|
'classifierModel', 'classifierApiKey', 'riskTimeoutMs', 'riskLearning', 'riskSediment',
|
|
219
243
|
'classifierSource', 'classifierProvider', 'riskThreshold', 'grantTtlMs', 'grantMaxUses',
|
|
220
|
-
'permissive', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'autoAllowTools',
|
|
244
|
+
'permissive', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'presetRulesSeeded', 'autoAllowTools',
|
|
221
245
|
'sessionSweep', 'searchUp', 'badFilePolicy', 'maxChainLength',
|
|
222
246
|
'networkEnabled', 'networkMode', 'networkUnlisted', 'networkUnattributed', 'networkLoopback',
|
|
223
247
|
'networkBind', 'networkPort', 'networkNoProxy', 'networkInjectEnv', 'networkAskTimeoutMs',
|
package/lib/engine.js
CHANGED
|
@@ -9,57 +9,15 @@
|
|
|
9
9
|
* Strictly fail-closed: P0 wins over every later stage.
|
|
10
10
|
*/
|
|
11
11
|
import { isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// `
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
12
|
+
import { commandIrreversibleReason, irreversibleReason } from './irreversible.js';
|
|
13
|
+
import { executableVariants, normalizeCommandText } from './command-normalize.js';
|
|
14
|
+
// 工具名名单归 `tool-names.ts` 所有(不得在此处再抄一份)。P0 **不看工具名白名单**:
|
|
15
|
+
// 它看 `executableCommandOf`——已知非执行器(read/write/内部工具)的 `command` 参数是数据,
|
|
16
|
+
// 其余任何带命令串的调用都按命令对待,所以工具叫什么(包括模型打错的 `pash`)都不是绕过面。
|
|
17
|
+
import { INTERNAL_TOOLS, READ_TOOLS, executableCommandOf } from './tool-names.js';
|
|
18
|
+
import { decomposeShellCommand } from './shell.js';
|
|
19
19
|
import { dispatchCommand } from './command-dispatcher.js';
|
|
20
20
|
const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|remove|rm|destroy|erase|purge|wipe|unlink|rmdir|reset)(?:$|[_-]|recursive)/i;
|
|
21
|
-
/**
|
|
22
|
-
* Read-only tools that can never modify the workspace or execute anything. P0
|
|
23
|
-
* still protects sensitive path reads outside the workspace root, and the
|
|
24
|
-
* deny-keyword layer still runs first, so a search whose query contains a
|
|
25
|
-
* blacklisted phrase is still vetoed.
|
|
26
|
-
*/
|
|
27
|
-
const READ_TOOLS = new Set([
|
|
28
|
-
'read', 'read_image', 'grep', 'glob', 'ls', 'lsp',
|
|
29
|
-
// Search / lookup: every one of these only READS. `web_search` and
|
|
30
|
-
// `modlens_read_image` also reach the network, but through DSH's own client
|
|
31
|
-
// rather than a subprocess — see the network note below.
|
|
32
|
-
'web_search', 'modlens_read_image',
|
|
33
|
-
'advanced_search', 'platform_search', 'free_search_test',
|
|
34
|
-
// Session-scoped reads: they disclose already-recorded state, never mutate.
|
|
35
|
-
'context_compression_retrieve', 'memory_search_graph', 'memory_expand_graph_node',
|
|
36
|
-
]);
|
|
37
|
-
/** DSH internal coordination / management tools — not workspace-modifying. */
|
|
38
|
-
const INTERNAL_TOOLS = new Set([
|
|
39
|
-
// AgentTeams coordination
|
|
40
|
-
'agent_teams_create', 'agent_teams_add_member', 'agent_teams_remove_member',
|
|
41
|
-
'agent_teams_delete', 'agent_teams_edit_plan', 'agent_teams_reassign_task',
|
|
42
|
-
'agent_teams_claim_task', 'agent_teams_create_task', 'agent_teams_send_message',
|
|
43
|
-
'agent_teams_approve', 'agent_teams_resume',
|
|
44
|
-
// AgentTeams status reads
|
|
45
|
-
'agent_teams_status', 'agent_teams_update_task',
|
|
46
|
-
// DSH session/memory/goal/taskboard management. The memory store is the
|
|
47
|
-
// agent's own state, not the workspace: writing it is not a workspace write.
|
|
48
|
-
'conversation_search',
|
|
49
|
-
'memory_add', 'memory_delete', 'memory_read_scene', 'memory_search',
|
|
50
|
-
'memory_import', 'memory_ruminate', 'memory_ruminate_cancel', 'memory_ruminate_status',
|
|
51
|
-
'get_goal', 'update_goal', 'create_goal',
|
|
52
|
-
'taskboard_get', 'taskboard_list', 'taskboard_claim', 'taskboard_block',
|
|
53
|
-
'taskboard_comment', 'taskboard_release_claim', 'taskboard_submit_review',
|
|
54
|
-
'taskboard_relate',
|
|
55
|
-
// Job/agent management
|
|
56
|
-
'job_list', 'job_output', 'job_kill',
|
|
57
|
-
'list_agents', 'interrupt_agent', 'send_message',
|
|
58
|
-
// Session-local UI / plan / todo state (no workspace write, no execution)
|
|
59
|
-
'todo_write', 'render_ui', 'validate_dsh_ui', 'ask_user_question', 'exit_plan_mode',
|
|
60
|
-
// Background / planning delegation (the delegated work is gated separately)
|
|
61
|
-
'subagent', 'subagent_fork', 'ralph', 'workflow', 'skill',
|
|
62
|
-
]);
|
|
63
21
|
/** No user-configured extra auto-allow names. */
|
|
64
22
|
const NO_EXTRA_AUTO_ALLOW = new Set();
|
|
65
23
|
/** The built-in read-only / internal classification, for diagnostics and docs. */
|
|
@@ -102,9 +60,41 @@ function operationText(args, depth = 0) {
|
|
|
102
60
|
}
|
|
103
61
|
return parts.join(' ');
|
|
104
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Credential or private-key material a call carries.
|
|
65
|
+
*
|
|
66
|
+
* Two clauses were removed on purpose, both because this predicate reads one flat
|
|
67
|
+
* string and therefore cannot tell *using* a secret from *moving* one — and P0 is
|
|
68
|
+
* the layer nobody can appeal:
|
|
69
|
+
*
|
|
70
|
+
* - `Bearer <token>`: putting a token in its own `Authorization` header is how every
|
|
71
|
+
* API is called. The benign-encoding corpus measured it as a false hard-deny
|
|
72
|
+
* (`curl -H "Authorization: Bearer …" https://api.internal/v1/me`); a command that
|
|
73
|
+
* cannot be shown to anyone is also a command that never reaches the popup.
|
|
74
|
+
* - a read-only look at a key file: `Test-Path ~/.ssh/id_rsa` moves nothing. The
|
|
75
|
+
* plan's own grading rule says so ("reading a local credential is not itself the
|
|
76
|
+
* act"), and the deny layer must not contradict the prompt it hands the model.
|
|
77
|
+
*
|
|
78
|
+
* What is left is the theft shape: a secret *artifact* plus a channel out, or any
|
|
79
|
+
* non-readonly verb touching one. That pairing lives in `irreversible.ts`, so both
|
|
80
|
+
* layers agree.
|
|
81
|
+
*/
|
|
82
|
+
const CREDENTIAL_MATERIAL = /(?:BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b|\.ssh[\\/](?:id_|config)|credentials\.yaml)/i;
|
|
83
|
+
/** Verbs that only look. A call made of nothing else cannot have leaked anything. */
|
|
84
|
+
const READ_ONLY_HEAD = /^(?:test-path|test|ls|dir|get-childitem|get-content|get-item|cat|type|more|head|tail|stat|file|select-string|where|which|exists)$/i;
|
|
85
|
+
/** Whether every segment of this command text is a read-only look. */
|
|
86
|
+
function readonlyProbe(text) {
|
|
87
|
+
const segments = text.split(/[;&|\n]+/).map((s) => s.trim()).filter((s) => s !== '');
|
|
88
|
+
if (segments.length === 0)
|
|
89
|
+
return false;
|
|
90
|
+
return segments.every((seg) => {
|
|
91
|
+
const head = (seg.replace(/^["'`]+/, '').split(/[\s/]+/)[0] ?? '').toLowerCase();
|
|
92
|
+
return READ_ONLY_HEAD.test(head);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
105
95
|
function containsCredentialMaterial(args) {
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
const text = operationText(args);
|
|
97
|
+
return CREDENTIAL_MATERIAL.test(text) && !readonlyProbe(text);
|
|
108
98
|
}
|
|
109
99
|
function pathArgument(args) {
|
|
110
100
|
for (const key of ['file_path', 'path', 'cwd', 'workdir']) {
|
|
@@ -116,6 +106,14 @@ function pathArgument(args) {
|
|
|
116
106
|
}
|
|
117
107
|
/** 廉价前缀过滤:绝大多数 shell 调用不是 push,先过一遍正则再动用解析器。 */
|
|
118
108
|
const GIT_PUSH_TEXT = /\bgit\s+push\b/;
|
|
109
|
+
/**
|
|
110
|
+
* Irreversibility itself is **not** defined here: it lives in `irreversible.ts`,
|
|
111
|
+
* shared with the tier grading in `ask-context.ts`. Two copies of that fact drift,
|
|
112
|
+
* and the drift was observed — `Optimize-VHD` was known to no rule while no graded
|
|
113
|
+
* model hard-denied it either. P0 consumes the shared judgement, and only ever
|
|
114
|
+
* turns it into a deny for the command as received plus the decoded variants the
|
|
115
|
+
* original will actually execute.
|
|
116
|
+
*/
|
|
119
117
|
/**
|
|
120
118
|
* 远端历史改写:`git push` 强制覆盖或删除**保护分支**。
|
|
121
119
|
*
|
|
@@ -158,23 +156,45 @@ export function hardDenyReason(ctx) {
|
|
|
158
156
|
const args = ctx.args;
|
|
159
157
|
if (containsCredentialMaterial(args))
|
|
160
158
|
return 'call contains credential or private-key material';
|
|
161
|
-
|
|
159
|
+
// **Shape, not a name whitelist** (see tool-names.ts): a call that carries a
|
|
160
|
+
// command string and is not a known non-executor is a command call whatever the
|
|
161
|
+
// tool is called, or an unfamiliar name would skip P0 with `diskpart /s x.txt`
|
|
162
|
+
// sitting right there in `args`.
|
|
163
|
+
const commandText = executableCommandOf(ctx.tool, args);
|
|
164
|
+
if (commandText !== undefined) {
|
|
165
|
+
// Encoded layers are resolved **first**: `-EncodedCommand` hides the act from
|
|
166
|
+
// every word-boundary rule below, and the visible shell form can itself be
|
|
167
|
+
// undecodable to the parser (unbalanced quotes), whose early exit must not
|
|
168
|
+
// also skip this check. Only variants the original hands to an execution
|
|
169
|
+
// primitive are trusted, and they can only ever add a deny — nothing here
|
|
170
|
+
// widens, and no decoded text is written back to the call or the log.
|
|
171
|
+
let decodedReason;
|
|
172
|
+
for (const variant of executableVariants(normalizeCommandText(commandText))) {
|
|
173
|
+
const hidden = irreversibleReason(variant);
|
|
174
|
+
if (hidden !== undefined) {
|
|
175
|
+
decodedReason = `${hidden} (decoded from an encoded layer)`;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
162
179
|
let commands = [];
|
|
163
180
|
try {
|
|
164
|
-
commands = decomposeShellCommand(
|
|
181
|
+
commands = decomposeShellCommand(commandText).commands;
|
|
165
182
|
}
|
|
166
183
|
catch {
|
|
167
|
-
|
|
184
|
+
// Undecidable syntax routes to ask (P4), never allow — unless a decode
|
|
185
|
+
// already proved an irreversible act.
|
|
186
|
+
return decodedReason;
|
|
168
187
|
}
|
|
169
188
|
for (const cmd of commands) {
|
|
170
|
-
if (cmd.command === 'rm' && (isRecursiveDeletion(cmd) || isForceDeletion(cmd))) {
|
|
171
|
-
// Guarded further by path rules; root-level deletion denied below only
|
|
172
|
-
// when the explicit target is a protected root.
|
|
173
|
-
}
|
|
174
189
|
const redir = cmd.redirects.find((r) => isProtectedDestructiveTarget(r, ctx.dshHome));
|
|
175
190
|
if (redir !== undefined)
|
|
176
191
|
return `shell redirect targets protected path: ${redir}`;
|
|
192
|
+
const wipe = commandIrreversibleReason(cmd);
|
|
193
|
+
if (wipe !== undefined)
|
|
194
|
+
return wipe;
|
|
177
195
|
}
|
|
196
|
+
if (decodedReason !== undefined)
|
|
197
|
+
return decodedReason;
|
|
178
198
|
const rewrite = gitRemoteRewriteReason(commands);
|
|
179
199
|
if (rewrite !== undefined)
|
|
180
200
|
return rewrite;
|
package/lib/evaluate.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ export interface Decision {
|
|
|
32
32
|
* command-content inspection below for the primary tool — every `git push`,
|
|
33
33
|
* `chmod`, redirect and `tee` would fall through to `defaultAction`.
|
|
34
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* Executor names, re-exported for the rule layer's `tools` dimension and for
|
|
37
|
+
* learning/appcontainer consumers. The list itself lives in `tool-names.ts` so the
|
|
38
|
+
* "is this an executed command?" question has one answer in the codebase.
|
|
39
|
+
*/
|
|
35
40
|
export declare const SHELL_TOOLS: Set<string>;
|
|
36
41
|
/** Whether one compiled rule matches the given call context. */
|
|
37
42
|
export declare function ruleMatches(rule: CompiledRuleEntry, ctx: ToolCallContext, commands: readonly SimpleCommand[], pathCands: readonly string[], urlCands: readonly string[]): boolean;
|
package/lib/evaluate.js
CHANGED
|
@@ -8,6 +8,7 @@ import { extractPathCandidates, extractUrlCandidates, } from './rule.js';
|
|
|
8
8
|
import { commandArgv, decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
|
|
9
9
|
import { compileCidr, compilePortSpec, compileDomainPattern } from './compiler.js';
|
|
10
10
|
import { dispatchCommand } from './command-dispatcher.js';
|
|
11
|
+
import { SHELL_TOOLS as IMPORTED_SHELL_TOOLS, WRITE_TOOLS as IMPORTED_WRITE_TOOLS, executableCommandOf, } from './tool-names.js';
|
|
11
12
|
/**
|
|
12
13
|
* Tool names that execute a shell command string. `shell` and `terminal` are
|
|
13
14
|
* DSH's own names (the shipped tool roster uses `shell`; `terminal` is the
|
|
@@ -16,7 +17,12 @@ import { dispatchCommand } from './command-dispatcher.js';
|
|
|
16
17
|
* command-content inspection below for the primary tool — every `git push`,
|
|
17
18
|
* `chmod`, redirect and `tee` would fall through to `defaultAction`.
|
|
18
19
|
*/
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Executor names, re-exported for the rule layer's `tools` dimension and for
|
|
22
|
+
* learning/appcontainer consumers. The list itself lives in `tool-names.ts` so the
|
|
23
|
+
* "is this an executed command?" question has one answer in the codebase.
|
|
24
|
+
*/
|
|
25
|
+
export const SHELL_TOOLS = IMPORTED_SHELL_TOOLS;
|
|
20
26
|
/** Whether one compiled rule matches the given call context. */
|
|
21
27
|
export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
22
28
|
if (!rule.enabled)
|
|
@@ -99,9 +105,14 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
99
105
|
// Join the FULL argv of each simple command: `c.command` alone is just the
|
|
100
106
|
// command word, so `curl https://x | sh` would read as `curl|sh` and the
|
|
101
107
|
// documented `curl|sh` pattern could never see its own subject.
|
|
102
|
-
const
|
|
108
|
+
const argvs = commands.map((c) => commandArgv(c));
|
|
109
|
+
const pipelineText = argvs.join('|');
|
|
103
110
|
const compiled = rule.argv.pipeline.map((p) => compileGlobForMatch(p));
|
|
104
|
-
|
|
111
|
+
// Match per simple command as well as over the joined pipeline: anchored
|
|
112
|
+
// prefix patterns (`git reset --hard*`) must see each segment on its own —
|
|
113
|
+
// `cd x && git reset --hard` hides the guarded segment behind the join —
|
|
114
|
+
// while cross-segment patterns (`curl|sh`) still read the joined string.
|
|
115
|
+
if (!compiled.some((g) => g.test(pipelineText) || argvs.some((s) => g.test(s))))
|
|
105
116
|
return false;
|
|
106
117
|
}
|
|
107
118
|
// network dimension: all present sub-dimensions must match.
|
|
@@ -118,7 +129,7 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
118
129
|
}
|
|
119
130
|
return true;
|
|
120
131
|
}
|
|
121
|
-
const WRITE_TOOLS =
|
|
132
|
+
const WRITE_TOOLS = IMPORTED_WRITE_TOOLS;
|
|
122
133
|
/**
|
|
123
134
|
* Shell write/execution patterns. When NONE of these match, the command is
|
|
124
135
|
* treated as read-only and follows the defaultAction (typically `allow`).
|
|
@@ -172,11 +183,9 @@ function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
|
|
|
172
183
|
return defaultAction;
|
|
173
184
|
}
|
|
174
185
|
// shell/pwsh/terminal: command-content-aware (write pattern → ask)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return 'ask';
|
|
179
|
-
}
|
|
186
|
+
const commandText = commandTextOf(ctx);
|
|
187
|
+
if (commandText !== undefined && isWriteCommand(commandText))
|
|
188
|
+
return 'ask';
|
|
180
189
|
return defaultAction;
|
|
181
190
|
}
|
|
182
191
|
/**
|
|
@@ -189,12 +198,14 @@ function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
|
|
|
189
198
|
function commandTextOf(ctx) {
|
|
190
199
|
if (typeof ctx.commandText === 'string')
|
|
191
200
|
return ctx.commandText;
|
|
192
|
-
|
|
193
|
-
return typeof raw === 'string' ? raw : undefined;
|
|
201
|
+
return executableCommandOf(ctx.tool, ctx.args);
|
|
194
202
|
}
|
|
195
203
|
/** Compute the deterministic first-match decision (P2 chain). */
|
|
196
204
|
export function decideRules(ruleset, ctx) {
|
|
197
|
-
|
|
205
|
+
// Command-bearing calls are decomposed by **shape**, not by whether the tool's
|
|
206
|
+
// name is in SHELL_TOOLS: an unfamiliar executor name must not make the command
|
|
207
|
+
// dimensions of every rule silently stop matching.
|
|
208
|
+
const shellText = commandTextOf(ctx);
|
|
198
209
|
const commands = shellText !== undefined ? decomposeSafe(shellText) : [];
|
|
199
210
|
const pathCands = extractPathCandidates(ctx.args);
|
|
200
211
|
const urlCands = extractUrlCandidates(ctx.args);
|
|
@@ -213,7 +224,7 @@ export function decideRules(ruleset, ctx) {
|
|
|
213
224
|
// shell/pwsh/terminal tools check the command for write patterns.
|
|
214
225
|
// Outside safety checks → ask; inside or read-only → defaultAction.
|
|
215
226
|
let action = ruleset.defaultAction;
|
|
216
|
-
if (WRITE_TOOLS.has(ctx.tool) ||
|
|
227
|
+
if (WRITE_TOOLS.has(ctx.tool) || shellText !== undefined) {
|
|
217
228
|
action = pathAndCommandDefault(ctx, pathCands, action);
|
|
218
229
|
}
|
|
219
230
|
if (action === 'allow' || action === 'ask') {
|
package/lib/events.d.ts
CHANGED
|
@@ -32,6 +32,27 @@ export interface GateEvent {
|
|
|
32
32
|
readonly learningCount?: number;
|
|
33
33
|
/** Confirmations required before that key auto-allows (manual-approve events). */
|
|
34
34
|
readonly threshold?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Salted digest of the redacted input behind this decision.
|
|
37
|
+
*
|
|
38
|
+
* Lives as its own field rather than riding in `reason`, because `reason` is
|
|
39
|
+
* truncated to REASON_MAX on write — attribution data must not be the part
|
|
40
|
+
* that gets cut. Raw arguments are never stored.
|
|
41
|
+
*/
|
|
42
|
+
readonly inputDigest?: string;
|
|
43
|
+
/** Which grading prompt produced this verdict, so wording changes are traceable. */
|
|
44
|
+
readonly promptVersion?: string;
|
|
45
|
+
/** Machine keys of "why this asked", so the panel can localize them later. */
|
|
46
|
+
readonly doubtKinds?: readonly string[];
|
|
47
|
+
/**
|
|
48
|
+
* The card the human actually decided from (why it asks / what it touches / what
|
|
49
|
+
* each button costs). Stored because a review list that shows only the audit
|
|
50
|
+
* `reason` cannot be read back: the person sees `no rule matched` where they
|
|
51
|
+
* once read a full explanation.
|
|
52
|
+
*/
|
|
53
|
+
readonly humanBrief?: string;
|
|
54
|
+
/** The redacted command, stored apart from the prose so a list can render it as code. */
|
|
55
|
+
readonly humanCommand?: string;
|
|
35
56
|
}
|
|
36
57
|
export interface GateEventInput {
|
|
37
58
|
readonly sessionId?: string;
|
|
@@ -46,6 +67,11 @@ export interface GateEventInput {
|
|
|
46
67
|
readonly files?: readonly string[];
|
|
47
68
|
readonly learningCount?: number;
|
|
48
69
|
readonly threshold?: number;
|
|
70
|
+
readonly inputDigest?: string;
|
|
71
|
+
readonly promptVersion?: string;
|
|
72
|
+
readonly doubtKinds?: readonly string[];
|
|
73
|
+
readonly humanBrief?: string;
|
|
74
|
+
readonly humanCommand?: string;
|
|
49
75
|
/** Session working directory, used to resolve relative snapshot paths. */
|
|
50
76
|
readonly baseDir?: string;
|
|
51
77
|
}
|
package/lib/events.js
CHANGED
|
@@ -264,6 +264,13 @@ export class EventLog {
|
|
|
264
264
|
...(files === undefined ? {} : { files }),
|
|
265
265
|
...(input.learningCount === undefined ? {} : { learningCount: input.learningCount }),
|
|
266
266
|
...(input.threshold === undefined ? {} : { threshold: input.threshold }),
|
|
267
|
+
...(input.inputDigest === undefined ? {} : { inputDigest: input.inputDigest }),
|
|
268
|
+
...(input.promptVersion === undefined ? {} : { promptVersion: input.promptVersion }),
|
|
269
|
+
...(input.doubtKinds === undefined || input.doubtKinds.length === 0 ? {} : { doubtKinds: input.doubtKinds.slice(0, 4) }),
|
|
270
|
+
// Bounded: this is a card, not a transcript, and the event file is read back
|
|
271
|
+
// into a UI list where a long entry pushes the rest off screen.
|
|
272
|
+
...(input.humanBrief === undefined ? {} : { humanBrief: String(input.humanBrief).slice(0, 600) }),
|
|
273
|
+
...(input.humanCommand === undefined || input.humanCommand === '' ? {} : { humanCommand: String(input.humanCommand).slice(0, 400) }),
|
|
267
274
|
};
|
|
268
275
|
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
269
276
|
appendFileSync(this.filePath, JSON.stringify(ev) + '\n', 'utf8');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grading rules shared by every grader in this plugin.
|
|
3
|
+
*
|
|
4
|
+
* `classifier.ts` and `risk.ts` are two protocols over the same transport, and
|
|
5
|
+
* their rule text was duplicated between them with different wording — one said
|
|
6
|
+
* "clearly safe" and "exfiltration-adjacent", the other carried the reversible
|
|
7
|
+
* exclusion list. A call could therefore be graded against two different
|
|
8
|
+
* standards depending on which path asked, which is how a local read
|
|
9
|
+
* (`cat .env`) ends up denied by one grader and allowed by the other.
|
|
10
|
+
*
|
|
11
|
+
* This module holds the rules once. Both protocols prepend their own output
|
|
12
|
+
* shape (three-way verdict vs safe/risky + category) and append these.
|
|
13
|
+
*
|
|
14
|
+
* It stays dependency-free so it can be imported from either half.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The substantive grading rules, in the order both graders should present them.
|
|
18
|
+
*
|
|
19
|
+
* Kept deliberately terse and measured: grading latency is prefill-dominated, and
|
|
20
|
+
* the first draft of these rules cost +42ms of p50 (gate: <= +20ms). What the
|
|
21
|
+
* runtime now enforces deterministically is not repeated here — the opaque-payload
|
|
22
|
+
* veto in `runtime.ts` refuses `safe` even when the grader grants it, so prose
|
|
23
|
+
* begging the model not to guess safe would only cost tokens. Rules below stay
|
|
24
|
+
* only where they change a judgement the code cannot make.
|
|
25
|
+
*
|
|
26
|
+
* Redundant restatements were merged once the whole prompt was re-measured at
|
|
27
|
+
* +36ms: the deletion list is one line instead of four, the read-only remote rule
|
|
28
|
+
* is one line instead of three. Same judgements, fewer tokens to read.
|
|
29
|
+
*/
|
|
30
|
+
export declare const GRADER_RULES: readonly string[];
|
|
31
|
+
/** Join the shared rules into prompt text. */
|
|
32
|
+
export declare function graderRulesText(): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grading rules shared by every grader in this plugin.
|
|
3
|
+
*
|
|
4
|
+
* `classifier.ts` and `risk.ts` are two protocols over the same transport, and
|
|
5
|
+
* their rule text was duplicated between them with different wording — one said
|
|
6
|
+
* "clearly safe" and "exfiltration-adjacent", the other carried the reversible
|
|
7
|
+
* exclusion list. A call could therefore be graded against two different
|
|
8
|
+
* standards depending on which path asked, which is how a local read
|
|
9
|
+
* (`cat .env`) ends up denied by one grader and allowed by the other.
|
|
10
|
+
*
|
|
11
|
+
* This module holds the rules once. Both protocols prepend their own output
|
|
12
|
+
* shape (three-way verdict vs safe/risky + category) and append these.
|
|
13
|
+
*
|
|
14
|
+
* It stays dependency-free so it can be imported from either half.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The substantive grading rules, in the order both graders should present them.
|
|
18
|
+
*
|
|
19
|
+
* Kept deliberately terse and measured: grading latency is prefill-dominated, and
|
|
20
|
+
* the first draft of these rules cost +42ms of p50 (gate: <= +20ms). What the
|
|
21
|
+
* runtime now enforces deterministically is not repeated here — the opaque-payload
|
|
22
|
+
* veto in `runtime.ts` refuses `safe` even when the grader grants it, so prose
|
|
23
|
+
* begging the model not to guess safe would only cost tokens. Rules below stay
|
|
24
|
+
* only where they change a judgement the code cannot make.
|
|
25
|
+
*
|
|
26
|
+
* Redundant restatements were merged once the whole prompt was re-measured at
|
|
27
|
+
* +36ms: the deletion list is one line instead of four, the read-only remote rule
|
|
28
|
+
* is one line instead of three. Same judgements, fewer tokens to read.
|
|
29
|
+
*/
|
|
30
|
+
export const GRADER_RULES = [
|
|
31
|
+
'Rules:',
|
|
32
|
+
'- safe is only for reversible, in-scope operations that touch no sensitive resource.',
|
|
33
|
+
'- When unsure between safe and neutral, answer neutral; never guess safe.',
|
|
34
|
+
'- Judge the operation content, not merely its location.',
|
|
35
|
+
'- "inside the workspace" is judged against workspace_root; if it is absent, treat the scope as unknown, not as inside.',
|
|
36
|
+
'- An encoded or unreadable payload is unknown, not proven safe.',
|
|
37
|
+
'',
|
|
38
|
+
'DELETION exclusion rules (these override the deletion category):',
|
|
39
|
+
'- Cleanup of temporary files, build outputs, session-local artifacts, or failed clone directories is SAFE even via rm/rmdir/Remove-Item — temp paths ("temp", "tmp", "test-clone"), and node_modules, .next, __pycache__, .pytest_cache, .cache, .git, dist, build are always regenerable.',
|
|
40
|
+
'- Only flag as deletion when the target is: (a) user data with no backup, (b) configuration files, (c) database records, (d) files outside the workspace that were not created by this session, or (e) a destructive rm -rf on a directory containing unique, irreplaceable content.',
|
|
41
|
+
'',
|
|
42
|
+
'READ-ONLY remote operations are safe: git fetch, git pull, git clone, git log, git diff, git status, git show, npm install (read-only), pip install (read-only), apt-get update, curl/wget (read, no write flag). Only flag as remote when the operation WRITES, DEPLOYS, PUSHES, or DELETES on the remote side.',
|
|
43
|
+
'- Reading a local credential is not itself the act; flag it only when its content leaves this machine.',
|
|
44
|
+
];
|
|
45
|
+
/** Join the shared rules into prompt text. */
|
|
46
|
+
export function graderRulesText() {
|
|
47
|
+
return GRADER_RULES.join('\n');
|
|
48
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { SandboxController } from './appcontainer/index.js';
|
|
4
4
|
import { Config, ensureDataDir, readRulesFromSettings, readVolatileValue, resolveVolatileConfig, resolveConfig, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
|
|
5
5
|
import { appendAllowToSettings, readRulesFileDoc, replaceAllowInSettings } from './allowlist.js';
|
|
6
|
+
import { mergePresetIntoRaw } from './preset-rules.js';
|
|
6
7
|
import { runDryRun } from './dry-run.js';
|
|
7
8
|
import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
|
|
8
9
|
// T12 设置数据桥(describe/mutate;client BridgeDocHandle 消费)
|
|
@@ -206,6 +207,25 @@ export function apply(ctx, config = {}) {
|
|
|
206
207
|
// the ref unset and the runtime falls back to its rules-file write paths.
|
|
207
208
|
const ENTRY_ID = 'dsh-perm-gate';
|
|
208
209
|
const settingsRef = {};
|
|
210
|
+
// 预设拒绝表播种:一次性、只走宿主设置面(写回落进 profile patch 的本插件
|
|
211
|
+
// config 条目)。刻意不再写插件自有的 `perm-gate/rules.yml` —— 那是用户手维护的
|
|
212
|
+
// 规则源,混进 51 条预设会让「我的规则」与「插件注入的规则」不可区分。
|
|
213
|
+
// 两条经实测定下的约束:① 必须在各分支「注册完成之后」调(0.1.5 上 ns 要等
|
|
214
|
+
// installSection 才注册,提前写报 `settings namespace ... is not registered`);
|
|
215
|
+
// ② 规则与幂等位必须合成一次 update(拆两次时 0.1.7 第二次报 `Plugin entry ... is
|
|
216
|
+
// no longer configurable`,幂等位永远落不下去 ⇒ 用户全删后会被重新塞回)。
|
|
217
|
+
// 写失败不阻断启动也不置幂等位 ⇒ 下次激活重试。
|
|
218
|
+
const seedPresetDenyTable = (svc) => {
|
|
219
|
+
void (async () => {
|
|
220
|
+
if (current().presetRulesSeeded === true)
|
|
221
|
+
return;
|
|
222
|
+
const merged = mergePresetIntoRaw(readVolatileValue(config.rules));
|
|
223
|
+
await svc.update(ENTRY_ID, { rules: merged.doc, presetRulesSeeded: true });
|
|
224
|
+
console.log(`[dsh-perm-gate] preset deny table seeded via settings (+${merged.added} rules)`);
|
|
225
|
+
})().catch((e) => {
|
|
226
|
+
console.warn('[dsh-perm-gate] preset deny table 播种失败(不阻断启动,下次激活重试):', e);
|
|
227
|
+
});
|
|
228
|
+
};
|
|
209
229
|
ctx.inject(['settings'], (child) => {
|
|
210
230
|
const svc = child.settings;
|
|
211
231
|
settingsRef.svc = svc;
|
|
@@ -253,6 +273,7 @@ export function apply(ctx, config = {}) {
|
|
|
253
273
|
console.warn('[dsh-perm-gate] legacy seed update failed:', e);
|
|
254
274
|
});
|
|
255
275
|
}
|
|
276
|
+
seedPresetDenyTable(svc);
|
|
256
277
|
return;
|
|
257
278
|
}
|
|
258
279
|
void (async () => {
|
|
@@ -279,6 +300,7 @@ export function apply(ctx, config = {}) {
|
|
|
279
300
|
console.warn('[dsh-perm-gate] legacy seed update failed:', e);
|
|
280
301
|
});
|
|
281
302
|
}
|
|
303
|
+
seedPresetDenyTable(svc);
|
|
282
304
|
return;
|
|
283
305
|
}
|
|
284
306
|
}
|
|
@@ -306,6 +328,7 @@ export function apply(ctx, config = {}) {
|
|
|
306
328
|
console.warn('[dsh-perm-gate] rules seed write failed:', e);
|
|
307
329
|
});
|
|
308
330
|
}
|
|
331
|
+
seedPresetDenyTable(svc);
|
|
309
332
|
})();
|
|
310
333
|
});
|
|
311
334
|
const settingsWrite = (write) => {
|
|
@@ -326,6 +349,7 @@ export function apply(ctx, config = {}) {
|
|
|
326
349
|
// entry that omits `config` still records events, snapshots and learning.
|
|
327
350
|
const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
|
|
328
351
|
const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
|
|
352
|
+
// 预设拒绝表的播种在上面的 settings inject 回调里(那里才有 svc)。
|
|
329
353
|
// Host model-group services (typed minimally; supplied by the dsh runtime
|
|
330
354
|
// through the loader `inject` — dsh-approval-gate demonstrates the same
|
|
331
355
|
// contract). The llm service backs the `host` receiver; agentDefaultModel
|
|
@@ -396,6 +420,11 @@ export function apply(ctx, config = {}) {
|
|
|
396
420
|
toolName: req.toolName,
|
|
397
421
|
...(req.callId !== undefined ? { callId: req.callId } : {}),
|
|
398
422
|
reason: req.reason,
|
|
423
|
+
// Host-sanctioned presentation field (`ApprovalRequestEvent.displayReason`):
|
|
424
|
+
// read by the answerer UI, deliberately not written to the audit pair. The
|
|
425
|
+
// service persists only id/toolName/callId/reason, so forwarding it costs
|
|
426
|
+
// nothing on hosts that ignore it.
|
|
427
|
+
...(req.displayReason !== undefined ? { displayReason: req.displayReason } : {}),
|
|
399
428
|
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
400
429
|
});
|
|
401
430
|
return APPROVAL_OUTCOMES.has(String(outcome))
|