dsh-perm-gate 5.1.2-compat.7 → 5.1.2

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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Command normalization — decode the layers a call hides behind, as a scan signal.
3
+ *
4
+ * Why this exists: every deterministic layer reads the command *as written*.
5
+ * `deny-defaults` is a word list, the parsers switch on `baseName`, and
6
+ * `isCleanupSafe` matches path segments. All three are blind to
7
+ * `powershell -EncodedCommand UgBlAG0A...`, to `R="-rf"; rm $R /`, and to a
8
+ * function wrapper around a wipe. Measured on the eval set, the encoded cases are
9
+ * exactly where a graded model ships an unrecoverable act past the gate
10
+ * (`adv_013` decodes to `Remove-Item -Recurse -Force C:\`, `adv_032` to
11
+ * `DROP DATABASE orders_prod`).
12
+ *
13
+ * Two rules keep this from becoming its own vulnerability:
14
+ *
15
+ * 1. **Additive only.** The caller feeds these strings to *stricter* scans
16
+ * (deny-keyword, P0 wipe detection, tier grading). Nothing here can widen a
17
+ * decision, and no result is written back to `exec.arguments` or the event
18
+ * log, so fingerprints, grants and audit text stay about the call as received.
19
+ * 2. **Bounded.** Length caps, depth cap, variant cap and a printable-ratio gate,
20
+ * because decoding is attacker-controlled input on the decision path. Oversized
21
+ * or undecodable input yields nothing, which leaves the call
22
+ * `targetResolvable=false` — opaque, never optimistically safe.
23
+ *
24
+ * On the plugin's own `--exec-b64` launcher wrapper: it is decoded like anything
25
+ * else rather than exempted. An exemption would hand the agent a disguise
26
+ * (`ac-launcher --config x --exec-b64 <rm -rf ...>`), while decoding it can only
27
+ * ever escalate — so the cost is at most a second confirmation, never a block
28
+ * that could not have been reached honestly.
29
+ */
30
+ /** Where a decoded string came from, and whether the original runs it as code. */
31
+ export interface NormalizedText {
32
+ readonly text: string;
33
+ readonly via: 'base64' | 'hex' | 'varsub';
34
+ /**
35
+ * True when the payload is handed to an execution primitive
36
+ * (`-EncodedCommand`, `FromBase64String`, `Invoke-Expression`, `eval`,
37
+ * `bash -c`, `xxd -r -p` …), i.e. it will really run. Only these variants may
38
+ * feed P0 hard-deny; a stray base64 blob in a comment must not.
39
+ */
40
+ readonly executed: boolean;
41
+ }
42
+ export interface NormalizeOptions {
43
+ /** Refuse to touch a command longer than this (DoS + false-positive guard). */
44
+ readonly maxInputChars?: number;
45
+ /** How many decode layers to peel. */
46
+ readonly maxDepth?: number;
47
+ /** Shortest base64 blob considered (below this, too noisy). */
48
+ readonly minBlobChars?: number;
49
+ /** Longest decoded output kept. */
50
+ readonly maxOutputChars?: number;
51
+ /** Hard cap on returned variants. */
52
+ readonly maxVariants?: number;
53
+ /**
54
+ * Wall-clock budget for the whole peel. On expiry **all** partial work is
55
+ * dropped, not just the remaining layers: a decoded fragment found late could
56
+ * turn into a P0 deny, and the guardrail this plugin cannot trade away is that
57
+ * the normalizer never decides — an expired peel leaves the call opaque, so the
58
+ * decision falls back to the ask it was already heading for.
59
+ */
60
+ readonly maxMs?: number;
61
+ /** Injected clock (tests drive the deadline without sleeping). */
62
+ readonly now?: () => number;
63
+ }
64
+ /**
65
+ * Peel encoding layers off one command and return every readable variant.
66
+ *
67
+ * Never throws: any failure yields whatever decoded fine, and an empty result
68
+ * simply means the call stays opaque to the deterministic layers. Exceeding the
69
+ * time budget returns **nothing** rather than the partial set (see `maxMs`).
70
+ */
71
+ export declare function normalizeCommandText(text: string, options?: NormalizeOptions): NormalizedText[];
72
+ /**
73
+ * Whether a command is the plugin's own AppContainer launcher wrapper.
74
+ * Informational only — the payload is still decoded and judged on its merits.
75
+ */
76
+ export declare function isOwnLauncher(text: string): boolean;
77
+ /** Variants strict enough to feed a deterministic deny: the payload actually runs. */
78
+ export declare function executableVariants(variants: readonly NormalizedText[]): string[];
79
+ /** All decoded variants, for the additive-only keyword/tier scans. */
80
+ export declare function allVariants(variants: readonly NormalizedText[]): string[];
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Command normalization — decode the layers a call hides behind, as a scan signal.
3
+ *
4
+ * Why this exists: every deterministic layer reads the command *as written*.
5
+ * `deny-defaults` is a word list, the parsers switch on `baseName`, and
6
+ * `isCleanupSafe` matches path segments. All three are blind to
7
+ * `powershell -EncodedCommand UgBlAG0A...`, to `R="-rf"; rm $R /`, and to a
8
+ * function wrapper around a wipe. Measured on the eval set, the encoded cases are
9
+ * exactly where a graded model ships an unrecoverable act past the gate
10
+ * (`adv_013` decodes to `Remove-Item -Recurse -Force C:\`, `adv_032` to
11
+ * `DROP DATABASE orders_prod`).
12
+ *
13
+ * Two rules keep this from becoming its own vulnerability:
14
+ *
15
+ * 1. **Additive only.** The caller feeds these strings to *stricter* scans
16
+ * (deny-keyword, P0 wipe detection, tier grading). Nothing here can widen a
17
+ * decision, and no result is written back to `exec.arguments` or the event
18
+ * log, so fingerprints, grants and audit text stay about the call as received.
19
+ * 2. **Bounded.** Length caps, depth cap, variant cap and a printable-ratio gate,
20
+ * because decoding is attacker-controlled input on the decision path. Oversized
21
+ * or undecodable input yields nothing, which leaves the call
22
+ * `targetResolvable=false` — opaque, never optimistically safe.
23
+ *
24
+ * On the plugin's own `--exec-b64` launcher wrapper: it is decoded like anything
25
+ * else rather than exempted. An exemption would hand the agent a disguise
26
+ * (`ac-launcher --config x --exec-b64 <rm -rf ...>`), while decoding it can only
27
+ * ever escalate — so the cost is at most a second confirmation, never a block
28
+ * that could not have been reached honestly.
29
+ */
30
+ const DEFAULTS = {
31
+ maxInputChars: 4096,
32
+ maxDepth: 3,
33
+ minBlobChars: 16,
34
+ maxOutputChars: 2048,
35
+ maxVariants: 8,
36
+ maxMs: 25,
37
+ now: Date.now,
38
+ };
39
+ /** Primitives that run a following encoded string / blob as code. */
40
+ const EXEC_PRIMITIVE = /(?:-EncodedCommand|FromBase64String|Invoke-Expression|\biex\b|\beval\b|bash\s+-c|sh\s+-c|pwsh\s+-c|powershell\s+-c|xxd\s+-r|--decode\b)/i;
41
+ /** base64 blobs long enough to be a real payload, quoted or bare. */
42
+ const B64_RE = /["']?([A-Za-z0-9+/]{16,}={0,2})["']?/g;
43
+ /** `\x72\x6d` escapes, or a spaced hex dump. */
44
+ const HEX_ESCAPE_RE = /\\x([0-9a-fA-F]{2})/g;
45
+ const HEX_PAIR_RE = /[0-9a-fA-F]{2}/g;
46
+ /**
47
+ * Simple assignments we can substitute textually: `X="v"`, `$X='v'`, `${X}: v`.
48
+ *
49
+ * The value is matched lazily without escape handling, because a Windows path is
50
+ * the common case and ends in a backslash right before the closing quote
51
+ * (`$t="C:\"`): an escape-aware pattern cannot find that terminator and silently
52
+ * yields nothing, which is the exact blind spot this folding exists to close.
53
+ * Truncating a value that contains a literal quote is acceptable here — variants
54
+ * only ever feed stricter scans.
55
+ */
56
+ const ASSIGN_RE = /\$?\{?([A-Za-z_][A-Za-z0-9_]*)\}?\s*=\s*(["'])(.*?)\2/g;
57
+ /** Share of decoded bytes that must be printable before we trust it as a command. */
58
+ const PRINTABLE_MIN = 0.85;
59
+ function printableRatio(text) {
60
+ if (text === '')
61
+ return 0;
62
+ let ok = 0;
63
+ for (const ch of text) {
64
+ const code = ch.codePointAt(0) ?? 0;
65
+ if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code !== 0x7f))
66
+ ok += 1;
67
+ }
68
+ return ok / text.length;
69
+ }
70
+ function clip(text, max) {
71
+ return text.length <= max ? text : text.slice(0, max);
72
+ }
73
+ /** Decoded text is only useful if it plausibly is a command, not binary noise. */
74
+ function looksLikeText(text, minPrintable = PRINTABLE_MIN) {
75
+ return text !== '' && printableRatio(text) >= minPrintable && /[A-Za-z]{3}/.test(text);
76
+ }
77
+ /** Decode a blob as one encoding; '' when it is not valid base64 at all. */
78
+ function decodeB64(blob, encoding) {
79
+ try {
80
+ return Buffer.from(blob, 'base64').toString(encoding);
81
+ }
82
+ catch {
83
+ return '';
84
+ }
85
+ }
86
+ /**
87
+ * Decode one base64 blob, or undefined when it is not decodable text.
88
+ *
89
+ * Two readings are attempted because they look identical from outside and both
90
+ * are common:
91
+ * - **utf16le** — `powershell/pwsh -EncodedCommand` and
92
+ * `[Convert]::ToBase64String([Text.Encoding]::Unicode…)` carry UTF-16LE. Read
93
+ * as UTF-8 the result is `R\0e\0m\0o\0v\0e…`, which a printable check alone
94
+ * rejects: a silent false negative on exactly the highest-value case, caught
95
+ * only once the test asserted the decoded text.
96
+ * - **utf8** — POSIX `base64 -d` and generic payloads.
97
+ */
98
+ function tryB64(blob, opts) {
99
+ if (blob.length < opts.minBlobChars)
100
+ return undefined;
101
+ const utf8 = decodeB64(blob, 'utf8');
102
+ const interleavedNull = /\x00/.test(utf8);
103
+ if (!interleavedNull && looksLikeText(utf8))
104
+ return clip(utf8, opts.maxOutputChars);
105
+ const utf16 = decodeB64(blob, 'utf16le');
106
+ if (looksLikeText(utf16))
107
+ return clip(utf16, opts.maxOutputChars);
108
+ if (looksLikeText(utf8))
109
+ return clip(utf8, opts.maxOutputChars);
110
+ return undefined;
111
+ }
112
+ /** Decode `\x72\x6d` escapes or a spaced hex dump into text. */
113
+ function tryHex(blob, opts) {
114
+ const escaped = [];
115
+ HEX_ESCAPE_RE.lastIndex = 0;
116
+ let e = HEX_ESCAPE_RE.exec(blob);
117
+ while (e !== null) {
118
+ escaped.push(parseInt(e[1] ?? '0', 16));
119
+ e = HEX_ESCAPE_RE.exec(blob);
120
+ }
121
+ const bytes = escaped.length > 0 ? escaped : [];
122
+ if (bytes.length === 0) {
123
+ const compact = blob.replace(/\s+/g, '');
124
+ if (compact.length % 2 !== 0)
125
+ return undefined;
126
+ HEX_PAIR_RE.lastIndex = 0;
127
+ let m = HEX_PAIR_RE.exec(compact);
128
+ while (m !== null) {
129
+ bytes.push(parseInt(m[0], 16));
130
+ m = HEX_PAIR_RE.exec(compact);
131
+ }
132
+ }
133
+ if (bytes.length === 0)
134
+ return undefined;
135
+ let hex;
136
+ try {
137
+ hex = Buffer.from(bytes).toString('utf8');
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ if (!looksLikeText(hex))
143
+ return undefined;
144
+ return clip(hex, opts.maxOutputChars);
145
+ }
146
+ /**
147
+ * Substitute `X="rm -rf"` / `$X` pairs so `rm $X /` becomes visible as one
148
+ * string. Only literal assignments are folded; nothing is evaluated.
149
+ */
150
+ function expandVars(text, opts) {
151
+ const table = new Map();
152
+ ASSIGN_RE.lastIndex = 0;
153
+ let m = ASSIGN_RE.exec(text);
154
+ while (m !== null) {
155
+ const name = m[1];
156
+ const value = m[3] ?? '';
157
+ if (name !== undefined && value !== '')
158
+ table.set(name, value);
159
+ m = ASSIGN_RE.exec(text);
160
+ }
161
+ if (table.size === 0)
162
+ return undefined;
163
+ let out = text;
164
+ for (const [name, value] of table) {
165
+ // Skip the assignment's own left-hand side (`$t=` …), which would otherwise
166
+ // be rewritten into noise like `C:\="C:\"`; only uses are folded.
167
+ const use = new RegExp(`\\$\\{?${name}\\}?(?!\\s*=)`, 'g');
168
+ out = out.replace(use, () => value);
169
+ }
170
+ return out === text ? undefined : clip(out, opts.maxOutputChars);
171
+ }
172
+ /**
173
+ * Peel encoding layers off one command and return every readable variant.
174
+ *
175
+ * Never throws: any failure yields whatever decoded fine, and an empty result
176
+ * simply means the call stays opaque to the deterministic layers. Exceeding the
177
+ * time budget returns **nothing** rather than the partial set (see `maxMs`).
178
+ */
179
+ export function normalizeCommandText(text, options = {}) {
180
+ const opts = { ...DEFAULTS, ...options };
181
+ if (typeof text !== 'string' || text.trim() === '' || text.length > opts.maxInputChars)
182
+ return [];
183
+ const startedAt = opts.now();
184
+ const spent = () => opts.now() - startedAt;
185
+ const seen = new Set();
186
+ const out = [];
187
+ const push = (variant) => {
188
+ const trimmed = variant.text.trim();
189
+ if (trimmed === '' || seen.has(trimmed))
190
+ return;
191
+ seen.add(trimmed);
192
+ out.push({ ...variant, text: trimmed });
193
+ };
194
+ let frontier = [{ text, executed: false }];
195
+ for (let depth = 0; depth < opts.maxDepth && frontier.length > 0 && out.length < opts.maxVariants; depth += 1) {
196
+ if (spent() > opts.maxMs)
197
+ return [];
198
+ const next = [];
199
+ for (const item of frontier) {
200
+ const runsCode = item.executed || EXEC_PRIMITIVE.test(item.text);
201
+ B64_RE.lastIndex = 0;
202
+ let b = B64_RE.exec(item.text);
203
+ while (b !== null) {
204
+ const decoded = tryB64(b[1] ?? '', opts);
205
+ if (decoded !== undefined) {
206
+ push({ text: decoded, via: 'base64', executed: runsCode });
207
+ next.push({ text: decoded, executed: runsCode });
208
+ }
209
+ b = B64_RE.exec(item.text);
210
+ }
211
+ if (/\\x[0-9a-fA-F]{2}/.test(item.text) || /(?:\b[0-9a-fA-F]{2}\b){6,}/.test(item.text)) {
212
+ const decoded = tryHex(item.text, opts);
213
+ if (decoded !== undefined) {
214
+ push({ text: decoded, via: 'hex', executed: runsCode });
215
+ next.push({ text: decoded, executed: runsCode });
216
+ }
217
+ }
218
+ const expanded = expandVars(item.text, opts);
219
+ if (expanded !== undefined) {
220
+ // A folded variable is the same command the shell will run, not a hidden
221
+ // payload, so it always counts as executed.
222
+ push({ text: expanded, via: 'varsub', executed: true });
223
+ next.push({ text: expanded, executed: true });
224
+ }
225
+ if (out.length >= opts.maxVariants)
226
+ break;
227
+ }
228
+ frontier = next;
229
+ }
230
+ return out.slice(0, opts.maxVariants);
231
+ }
232
+ /**
233
+ * Whether a command is the plugin's own AppContainer launcher wrapper.
234
+ * Informational only — the payload is still decoded and judged on its merits.
235
+ */
236
+ export function isOwnLauncher(text) {
237
+ return /--exec-b64\s+[A-Za-z0-9+/=]{8,}/.test(text) && /--config\s+/.test(text);
238
+ }
239
+ /** Variants strict enough to feed a deterministic deny: the payload actually runs. */
240
+ export function executableVariants(variants) {
241
+ return variants.filter((v) => v.executed).map((v) => v.text);
242
+ }
243
+ /** All decoded variants, for the additive-only keyword/tier scans. */
244
+ export function allVariants(variants) {
245
+ return variants.map((v) => v.text);
246
+ }
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
- // SHELL_TOOLS 是**值的导入**:这份名单必须与 evaluate.ts / runtime.ts 共用同一个
13
- // 事实源。此处曾经自带一份 `/(?:bash|pwsh|sh|cmd)$/` 正则,漏掉了 `shell` /
14
- // `terminal` / `powershell`——于是 P0 的 shell 检查对 DSH 的**主 shell 工具**
15
- // 完全失效(实测:同一句 `echo x > /etc/passwd`,bash 被拦截,shell 放行)。
16
- // 同名事实的第二份副本必然腐烂,这里不再保留副本。
17
- import { SHELL_TOOLS } from './evaluate.js';
18
- import { isForceDeletion, isRecursiveDeletion, decomposeShellCommand } from './shell.js';
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
- return /(?:BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b|Bearer\s+[A-Za-z0-9._~+\/-]{8,}|\.ssh[\\/](?:id_|config)|credentials\.yaml)/i
107
- .test(operationText(args));
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
- if (SHELL_TOOLS.has(ctx.tool) && typeof args.command === 'string') {
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(args.command).commands;
181
+ commands = decomposeShellCommand(commandText).commands;
165
182
  }
166
183
  catch {
167
- return undefined; // undecidable syntax -> route to ask (P4), never allow
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
- export const SHELL_TOOLS = new Set(['shell', 'terminal', 'bash', 'pwsh', 'sh', 'cmd', 'powershell']);
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)
@@ -118,7 +124,7 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
118
124
  }
119
125
  return true;
120
126
  }
121
- const WRITE_TOOLS = new Set(['write', 'edit']);
127
+ const WRITE_TOOLS = IMPORTED_WRITE_TOOLS;
122
128
  /**
123
129
  * Shell write/execution patterns. When NONE of these match, the command is
124
130
  * treated as read-only and follows the defaultAction (typically `allow`).
@@ -172,11 +178,9 @@ function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
172
178
  return defaultAction;
173
179
  }
174
180
  // shell/pwsh/terminal: command-content-aware (write pattern → ask)
175
- if (SHELL_TOOLS.has(ctx.tool)) {
176
- const commandText = commandTextOf(ctx);
177
- if (commandText !== undefined && isWriteCommand(commandText))
178
- return 'ask';
179
- }
181
+ const commandText = commandTextOf(ctx);
182
+ if (commandText !== undefined && isWriteCommand(commandText))
183
+ return 'ask';
180
184
  return defaultAction;
181
185
  }
182
186
  /**
@@ -189,12 +193,14 @@ function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
189
193
  function commandTextOf(ctx) {
190
194
  if (typeof ctx.commandText === 'string')
191
195
  return ctx.commandText;
192
- const raw = ctx.args?.['command'];
193
- return typeof raw === 'string' ? raw : undefined;
196
+ return executableCommandOf(ctx.tool, ctx.args);
194
197
  }
195
198
  /** Compute the deterministic first-match decision (P2 chain). */
196
199
  export function decideRules(ruleset, ctx) {
197
- const shellText = SHELL_TOOLS.has(ctx.tool) ? commandTextOf(ctx) : undefined;
200
+ // Command-bearing calls are decomposed by **shape**, not by whether the tool's
201
+ // name is in SHELL_TOOLS: an unfamiliar executor name must not make the command
202
+ // dimensions of every rule silently stop matching.
203
+ const shellText = commandTextOf(ctx);
198
204
  const commands = shellText !== undefined ? decomposeSafe(shellText) : [];
199
205
  const pathCands = extractPathCandidates(ctx.args);
200
206
  const urlCands = extractUrlCandidates(ctx.args);
@@ -213,7 +219,7 @@ export function decideRules(ruleset, ctx) {
213
219
  // shell/pwsh/terminal tools check the command for write patterns.
214
220
  // Outside safety checks → ask; inside or read-only → defaultAction.
215
221
  let action = ruleset.defaultAction;
216
- if (WRITE_TOOLS.has(ctx.tool) || SHELL_TOOLS.has(ctx.tool)) {
222
+ if (WRITE_TOOLS.has(ctx.tool) || shellText !== undefined) {
217
223
  action = pathAndCommandDefault(ctx, pathCands, action);
218
224
  }
219
225
  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');