@miphamai/cli 0.81.7 → 0.81.9

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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/bin/mipham.ts +35 -1
  3. package/package.json +1 -1
  4. package/src/agent/message-bus.ts +10 -3
  5. package/src/agent/sub-agent.ts +60 -12
  6. package/src/agent/types.ts +14 -1
  7. package/src/artifacts/manifest.ts +90 -34
  8. package/src/artifacts/paths.ts +19 -0
  9. package/src/artifacts/server.ts +48 -8
  10. package/src/config/credential-crypto.ts +28 -5
  11. package/src/config/defaults.ts +18 -10
  12. package/src/config/keys-manager.ts +14 -9
  13. package/src/config/loader.ts +202 -63
  14. package/src/config/preferences.ts +5 -2
  15. package/src/core/credential-masker/output-scrub.ts +16 -2
  16. package/src/core/cron-poller.ts +30 -6
  17. package/src/core/engine.ts +7 -2
  18. package/src/core/hooks-executor.ts +30 -2
  19. package/src/core/hooks.ts +51 -4
  20. package/src/core/paths.ts +44 -1
  21. package/src/core/permission-config.ts +146 -14
  22. package/src/core/permission-rules.ts +157 -6
  23. package/src/core/permission.ts +81 -13
  24. package/src/core/rules-loader.ts +35 -5
  25. package/src/core/session-log.ts +49 -2
  26. package/src/core/session-store.ts +11 -1
  27. package/src/core/workspace-trust.ts +42 -4
  28. package/src/daemon/auth.ts +15 -14
  29. package/src/daemon/engine-capabilities.ts +12 -2
  30. package/src/daemon/remote-engine.ts +9 -4
  31. package/src/daemon/server.ts +29 -1
  32. package/src/daemon/session-worker.ts +15 -0
  33. package/src/i18n-core/locales/en-US.json +12 -8
  34. package/src/i18n-core/locales/zh-CN.json +12 -8
  35. package/src/index.tsx +47 -19
  36. package/src/mcp/client.ts +24 -0
  37. package/src/mcp/http-transport.ts +35 -3
  38. package/src/plugin/plugin-manager.ts +30 -8
  39. package/src/providers/anthropic.ts +74 -13
  40. package/src/providers/openai-compat.ts +14 -1
  41. package/src/security/gate.ts +18 -0
  42. package/src/security/path.ts +25 -2
  43. package/src/shared/arg-validation.ts +37 -2
  44. package/src/shared/atomic-write.ts +28 -5
  45. package/src/shared/package-info.ts +1 -1
  46. package/src/shared/sanitize.ts +27 -2
  47. package/src/shared/types.ts +17 -0
  48. package/src/shared/update.ts +22 -5
  49. package/src/tools/agent/agent.ts +3 -0
  50. package/src/tools/artifact/artifact.ts +14 -4
  51. package/src/tools/exec/bash.ts +146 -24
  52. package/src/tools/exec/enter-worktree.ts +9 -3
  53. package/src/tools/exec/exit-worktree.ts +6 -3
  54. package/src/tools/exec/git.ts +83 -3
  55. package/src/tools/file/glob.ts +19 -3
  56. package/src/tools/file/grep.ts +70 -16
  57. package/src/tools/file/read.ts +151 -45
  58. package/src/tools/index.ts +12 -4
  59. package/src/tools/scheduling/cron.ts +34 -5
  60. package/src/tools/system/config.ts +6 -2
  61. package/src/ui/app.tsx +47 -11
  62. package/src/ui/commands.ts +187 -41
  63. package/src/workflow/primitives/agent.ts +4 -0
  64. package/src/artifacts/versioning.ts +0 -127
package/src/core/hooks.ts CHANGED
@@ -48,6 +48,18 @@ export class HookEngine {
48
48
  /** Health tracking per hook key (event[:toolName]) */
49
49
  private health = new Map<string, HookHealth>()
50
50
 
51
+ /**
52
+ * The workspace this engine's hooks run *for*.
53
+ *
54
+ * Settled at construction because one engine belongs to one session, not to one
55
+ * hook. The default is exactly right for the one-shot CLI, whose process cwd
56
+ * *is* the session cwd — but the daemon serves many sessions from one process,
57
+ * so it must pass the session's cwd. Left to the executor's own
58
+ * `process.cwd()`, every daemon hook would run in — and be told it is in — the
59
+ * directory the daemon happened to be started from.
60
+ */
61
+ constructor(private readonly cwd: string = process.cwd()) {}
62
+
51
63
  register(hook: HookDefinition): void {
52
64
  this.hooks.push(hook)
53
65
  }
@@ -98,7 +110,12 @@ export class HookEngine {
98
110
 
99
111
  async executeStop(sessionId: string): Promise<HookResult> {
100
112
  const ctx: HookContext = { event: 'Stop', sessionId }
101
- return this.runHooks('Stop', undefined, ctx)
113
+ const result = await this.runHooks('Stop', undefined, ctx)
114
+
115
+ // A blocking Stop hook arrives as a deny (exit code 2, `decision: block`, or
116
+ // `continue: false`), but the engine's Stop path reads `decision`. Derive it
117
+ // here rather than at each producer so every form reaches "do not stop yet".
118
+ return result.allowed ? result : { ...result, decision: 'block' }
102
119
  }
103
120
 
104
121
  async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
@@ -134,9 +151,12 @@ export class HookEngine {
134
151
  const ctx: HookContext = {
135
152
  event: 'SubagentStart',
136
153
  sessionId,
154
+ // The agent type plays the role a tool name plays for PreToolUse: it is
155
+ // what a settings.json `matcher` selects on.
156
+ toolName: agentType,
137
157
  toolInput: { agentType, description },
138
158
  }
139
- return this.runHooks('SubagentStart', undefined, ctx)
159
+ return this.runHooks('SubagentStart', agentType, ctx)
140
160
  }
141
161
 
142
162
  async executeSubagentStop(
@@ -149,10 +169,12 @@ export class HookEngine {
149
169
  const ctx: HookContext = {
150
170
  event: 'SubagentStop',
151
171
  sessionId,
172
+ // Matcher target — see executeSubagentStart.
173
+ toolName: agentType,
152
174
  toolInput: { agentType, description, success },
153
175
  toolResult: result ? { success, content: result.slice(0, 2000) } : undefined,
154
176
  }
155
- return this.runHooks('SubagentStop', undefined, ctx)
177
+ return this.runHooks('SubagentStop', agentType, ctx)
156
178
  }
157
179
 
158
180
  async executePostToolUseFailure(
@@ -268,15 +290,40 @@ export class HookEngine {
268
290
 
269
291
  // ── Core execution ──
270
292
 
293
+ /**
294
+ * Does a hook's `matcher` select this invocation?
295
+ *
296
+ * No matcher means every invocation of the event. Otherwise the stored matcher
297
+ * is a regex — `loadHookConfigs` compiles it as one — tested against the name
298
+ * this event filters on: the tool name for tool events, the agent type for the
299
+ * subagent events. Events that carry no such name (Stop, SessionStart, …) are
300
+ * not filtered here.
301
+ */
302
+ private matchesMatcher(matcher: string | undefined, name: string | undefined): boolean {
303
+ if (!matcher || !name) return true
304
+ try {
305
+ return new RegExp(matcher).test(name)
306
+ } catch {
307
+ // An uncompilable pattern cannot get this far through loadHookConfigs,
308
+ // which compiles every matcher when it loads. Keep such a hook running
309
+ // rather than dropping it silently.
310
+ return true
311
+ }
312
+ }
313
+
271
314
  private async runHooks(
272
315
  event: HookEvent,
273
316
  toolName: string | undefined,
274
317
  ctx: HookContext,
275
318
  ): Promise<HookResult> {
276
319
  const matching = this.hooks.filter(
277
- (h) => h.event === event && (!toolName || !h.toolName || h.toolName === toolName),
320
+ (h) => h.event === event && this.matchesMatcher(h.toolName, toolName),
278
321
  )
279
322
 
323
+ // Stamped here rather than at each `executeX`: the cwd is a property of the
324
+ // engine, and every context this engine hands out needs it.
325
+ ctx.cwd = this.cwd
326
+
280
327
  const result: HookResult = { allowed: true }
281
328
 
282
329
  for (const hook of matching) {
package/src/core/paths.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  * 突然失去隔离保护(隔离度只许增不许减)。
8
8
  */
9
9
 
10
+ import { realpathSync } from 'node:fs'
10
11
  import { homedir } from 'node:os'
11
- import { join } from 'node:path'
12
+ import { basename, dirname, join } from 'node:path'
12
13
  import { MIPHAM_DIR } from '../shared/constants.ts'
13
14
 
14
15
  /** 只读兼容目录名。 */
@@ -33,6 +34,48 @@ export function worktreeRoots(cwd: string): string[] {
33
34
  return [worktreeRoot(cwd), join(cwd, LEGACY_CLAUDE_DIR, 'worktrees')]
34
35
  }
35
36
 
37
+ /**
38
+ * 规范化 worktree 路径,两侧都过一遍才谈得上比较。
39
+ *
40
+ * 叶子不存在是常态(工作树已被删、或路径是模型编出来的),此时 `realpathSync`
41
+ * 会抛 —— 那就只规范父目录、最后一段按原样留着,否则「叶子没了」会被误读成
42
+ * 「拼法不同」(明明同一个路径,却因为 P 的拼法与 git 打印的不同而判成不在)。
43
+ */
44
+ function canonicalWorktreePath(path: string): string {
45
+ const trimmed = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path
46
+ try {
47
+ return realpathSync(trimmed)
48
+ } catch {
49
+ try {
50
+ return join(realpathSync(dirname(trimmed)), basename(trimmed))
51
+ } catch {
52
+ return trimmed
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `git worktree list --porcelain` 里是否**确实**列出了 `target` 这个工作树。
59
+ *
60
+ * 不能用 `output.includes(target)`:那是子串判定,本机实测(真 git,建出 `w1`
61
+ * 与 `w10`)它错在三个方向 ——
62
+ * - `.../w1` 命中 **`.../w10` 那一行**(前缀当成同一个)⇒ 不存在被判成存在。
63
+ * EnterWorktree 那侧因此连 `w1` 都建不出来:明明没有,它报 already exists;
64
+ * - 带尾斜杠的 `.../w1/` 一行都不命中 ⇒ 存在被判成 not found;
65
+ * - git 打印 **realpath 拼法**(`mktemp -d /tmp/x` 建的在 porcelain 里是
66
+ * `/private/tmp/x/...`)⇒ 别名拼法一头都命中不了,而 EnterWorktree 的成功
67
+ * 文案里印的正是它自己算出来的那个拼法,模型照抄回来必然吃 not found。
68
+ *
69
+ * 判据是**相等**(名字比对),不是包含 —— 工作树列表里列的就是工作树根。
70
+ */
71
+ export function listsWorktree(output: string, target: string): boolean {
72
+ const want = canonicalWorktreePath(target)
73
+ return output
74
+ .split('\n')
75
+ .filter((line) => line.startsWith('worktree '))
76
+ .some((line) => canonicalWorktreePath(line.slice('worktree '.length).trim()) === want)
77
+ }
78
+
36
79
  /**
37
80
  * 在 `cwd` 中定位 worktree 标记,返回项目根与命中的标记。
38
81
  * 不在任何 worktree 内时返回 null。
@@ -20,21 +20,161 @@ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): Permi
20
20
  }
21
21
 
22
22
  /**
23
- * Permission modes ordered from least to most permissive.
24
- * Used to enforce maxAllowedMode: any mode ranked higher than the cap is forbidden.
23
+ * Nominal permissiveness ranking, least → most permissive. Two consumers only:
24
+ * `maxAllowedMode` (drop every mode ranked above the cap) and `clampMode` (walk
25
+ * downward to the nearest allowed mode below the one requested).
26
+ *
27
+ * **The four modes are not totally ordered in reality**, so this array carries
28
+ * only the relations that are actually measurable:
29
+ *
30
+ * - `plan` is strictly the narrowest. It passes only Read/Grep/Glob and sends
31
+ * *everything* else to approval, while `default` passes every tool that
32
+ * declares `permission: 'auto'` — git, task, web-fetch, cron, memory, … So a
33
+ * cap of `'plan'` must not admit `default`, and `plan` belongs at the bottom.
34
+ * - `acceptEdits` and `default` are **incomparable**: acceptEdits auto-approves
35
+ * Write/Edit and verification-only Bash that `default` asks about, while
36
+ * `default` auto-approves the non-file `'auto'` tools that acceptEdits asks
37
+ * about. No total order is faithful there, so the ranking only needs to carry
38
+ * the relations the two consumers rely on.
39
+ *
40
+ * The array used to read `default → acceptEdits → plan → …`, which **inverted**
41
+ * both `plan` relations rather than merely approximating them: `maxAllowedMode:
42
+ * 'plan'` admitted acceptEdits *and* default — the ceiling let through the wider
43
+ * mode each time. The pairs are pinned by a probe in `test/core/permission.test.ts`
44
+ * (P4) so the claim stays measured rather than asserted.
25
45
  */
26
46
  export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
47
+ 'plan',
27
48
  'default',
28
49
  'acceptEdits',
29
- 'plan',
30
50
  'bypassPermissions',
31
51
  ]
32
52
 
33
- /** Valid mode transition order for Shift+Tab cycling. */
34
- export const MODE_CYCLE: PermissionMode[] = [...PERMISSION_MODE_HIERARCHY]
53
+ /**
54
+ * Shift+Tab cycling order — deliberately **not** the permissiveness order above.
55
+ * The cycle is UX (manual → accept edits → plan → bypass); only the hierarchy
56
+ * answers "is this mode wider than that one". Keeping them separate is what lets
57
+ * `forbiddenModes` drop an entry from the cycle without disturbing the ranking
58
+ * that `clampMode` walks.
59
+ */
60
+ export const MODE_CYCLE: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']
61
+
62
+ /** 规范形 → 把别名与大小写归一到一个键上(键一律小写)。 */
63
+ const MODE_ALIASES: Record<string, PermissionMode> = {
64
+ default: 'default',
65
+ plan: 'plan',
66
+ acceptedits: 'acceptEdits',
67
+ bypasspermissions: 'bypassPermissions',
68
+ bypass: 'bypassPermissions', // 遗留 3 档名(PermissionLevel 里的 'bypass')
69
+ }
70
+
71
+ /** 认不出的配置一律按这一档收紧 —— 层级表首位即最严的一档(与 P4 同一真源)。 */
72
+ const STRICTEST_MODE: PermissionMode = PERMISSION_MODE_HIERARCHY[0]!
73
+
74
+ const VALID_MODE_LIST = 'default, plan, acceptEdits, bypassPermissions'
75
+
76
+ /** 可读的类型名 —— 报错要说清「你给的是个字符串」,而不是只说 invalid。 */
77
+ function describeValue(value: unknown): string {
78
+ if (value === null) return 'null'
79
+ if (Array.isArray(value)) return 'array'
80
+ return typeof value
81
+ }
82
+
83
+ /** 认得出就返回规范形,认不出返回 undefined(调用方负责告警)。 */
84
+ function normalizeModeName(value: unknown): PermissionMode | undefined {
85
+ if (typeof value !== 'string') return undefined
86
+ return MODE_ALIASES[value.trim().toLowerCase()]
87
+ }
88
+
89
+ /**
90
+ * 校验并规范化 `permissionRestrictions`。
91
+ *
92
+ * 与 `getInvalidRules()` 同一形状:写错的**规则**一直会被告警,写错的**限制**却不会
93
+ * —— 而限制写错的失效方向是 **fail-open**:`forbiddenModes` 里的错拼一个模式都匹配
94
+ * 不上;`maxAllowedMode` 认不出时 `indexOf` 返回 -1,`if (capIdx >= 0)` 之后整个上限
95
+ * 被跳过。于是配置里一个 typo 就让整条组织级策略静默失效,且无任何提示。
96
+ *
97
+ * `restrictions` 是规范化后的值(别名与大小写归一、认不出的条目剔除);
98
+ * `invalid` 是逐条可读告警。**只要有任意一条认不出来,就按最严一档封顶** ——
99
+ * 拒绝而不是忽略(忽略就是上面那种 fail-open)。
100
+ *
101
+ * 幂等:已规范化的值再喂一次,`invalid` 必为空(子代理会原样转交一次)。
102
+ */
103
+ export function normalizeRestrictions(raw: unknown): {
104
+ restrictions?: PermissionRestrictions
105
+ invalid: string[]
106
+ } {
107
+ if (raw === undefined || raw === null) return { restrictions: undefined, invalid: [] }
108
+
109
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
110
+ return {
111
+ restrictions: { maxAllowedMode: STRICTEST_MODE },
112
+ invalid: [`permissionRestrictions is not an object (got ${describeValue(raw)})`],
113
+ }
114
+ }
115
+
116
+ const source = raw as Record<string, unknown>
117
+ const invalid: string[] = []
118
+ const forbiddenModes: PermissionMode[] = []
119
+ let maxAllowedMode: PermissionMode | undefined
120
+ let sawForbidden = false
121
+ let sawMaxAllowed = false
122
+
123
+ for (const key of Object.keys(source)) {
124
+ if (key !== 'forbiddenModes' && key !== 'maxAllowedMode') {
125
+ invalid.push(
126
+ `permissionRestrictions has an unknown key "${key}"; valid keys: forbiddenModes, maxAllowedMode`,
127
+ )
128
+ }
129
+ }
130
+
131
+ if (source.forbiddenModes !== undefined) {
132
+ sawForbidden = true
133
+ if (!Array.isArray(source.forbiddenModes)) {
134
+ invalid.push(
135
+ `permissionRestrictions.forbiddenModes must be an array (got ${describeValue(source.forbiddenModes)})`,
136
+ )
137
+ } else {
138
+ source.forbiddenModes.forEach((entry: unknown, i: number) => {
139
+ const mode = normalizeModeName(entry)
140
+ if (mode) forbiddenModes.push(mode)
141
+ else
142
+ invalid.push(
143
+ `permissionRestrictions.forbiddenModes[${i}] is not a permission mode (${JSON.stringify(entry)}); valid: ${VALID_MODE_LIST}`,
144
+ )
145
+ })
146
+ }
147
+ }
148
+
149
+ if (source.maxAllowedMode !== undefined) {
150
+ sawMaxAllowed = true
151
+ const mode = normalizeModeName(source.maxAllowedMode)
152
+ if (mode) maxAllowedMode = mode
153
+ else
154
+ invalid.push(
155
+ `permissionRestrictions.maxAllowedMode is not a permission mode (${JSON.stringify(source.maxAllowedMode)}); valid: ${VALID_MODE_LIST}`,
156
+ )
157
+ }
158
+
159
+ if (invalid.length > 0) {
160
+ // fail-closed:认不出来就按最严一档封顶。识别得出的部分照旧保留。
161
+ invalid.push(
162
+ `permissionRestrictions could not be fully parsed → mode pinned to the strictest ("${STRICTEST_MODE}")`,
163
+ )
164
+ const restrictions: PermissionRestrictions = { maxAllowedMode: STRICTEST_MODE }
165
+ if (forbiddenModes.length > 0) restrictions.forbiddenModes = forbiddenModes
166
+ return { restrictions, invalid }
167
+ }
168
+
169
+ const restrictions: PermissionRestrictions = {}
170
+ if (sawForbidden) restrictions.forbiddenModes = forbiddenModes
171
+ if (sawMaxAllowed) restrictions.maxAllowedMode = maxAllowedMode
172
+ if (Object.keys(restrictions).length === 0) return { restrictions: undefined, invalid: [] }
173
+ return { restrictions, invalid }
174
+ }
35
175
 
36
176
  /** Resolve which modes are actually permitted given the restrictions. */
37
- export function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
177
+ function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
38
178
  let allowed = [...MODE_CYCLE]
39
179
 
40
180
  if (restrictions?.forbiddenModes && restrictions.forbiddenModes.length > 0) {
@@ -52,14 +192,6 @@ export function getAllowedModes(restrictions?: PermissionRestrictions): Permissi
52
192
  return allowed
53
193
  }
54
194
 
55
- /** Check whether a given mode is permitted under the restrictions. */
56
- export function isModeAllowed(
57
- mode: PermissionMode,
58
- restrictions?: PermissionRestrictions,
59
- ): boolean {
60
- return getAllowedModes(restrictions).includes(mode)
61
- }
62
-
63
195
  /**
64
196
  * Return the highest allowed mode at or below `desired` given the restrictions.
65
197
  * Used to silently downgrade when a forbidden mode is requested.
@@ -1,3 +1,5 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
1
3
  import type { PermissionRuleEntry } from '../shared/index.ts'
2
4
  import { matchPath } from './credential-masker/matcher'
3
5
 
@@ -106,6 +108,9 @@ const PREFIX_COMMANDS = new Set([
106
108
  'stdbuf',
107
109
  ])
108
110
 
111
+ /** `timeout` 的 duration 形态:`5` / `0.5` / `30s` / `2m` / `1h` / `1d`。 */
112
+ const TIMEOUT_DURATION_RE = /^\d+(\.\d+)?[smhd]?$/
113
+
109
114
  /** Value-taking options of wrapper commands (consume the following token). */
110
115
  const PREFIX_VALUE_OPTIONS = new Set([
111
116
  '-u',
@@ -193,6 +198,76 @@ function stripQuotes(s: string): string {
193
198
  return s
194
199
  }
195
200
 
201
+ /** Shell 结构符号:分组与取反 —— 它们自身不是命令,紧跟其后的是。 */
202
+ const LEADING_SHELL_PUNCT = new Set(['(', '{', '!'])
203
+
204
+ /** Shell 关键字:其后才是真正的命令(`do rm -rf x` 执行的命令是 `rm`)。 */
205
+ const LEADING_SHELL_KEYWORDS = new Set([
206
+ 'do',
207
+ 'then',
208
+ 'else',
209
+ 'elif',
210
+ 'if',
211
+ 'while',
212
+ 'until',
213
+ 'for',
214
+ 'case',
215
+ 'time',
216
+ 'coproc',
217
+ ])
218
+
219
+ /** 一条前导赋值:`NAME=value`(name 是合法标识符,`=` 前无引号)。 */
220
+ const LEADING_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/
221
+
222
+ /**
223
+ * 剥掉一段 shell 片段**前导**的噪声 token,露出真正的基命令。
224
+ *
225
+ * 为什么需要它:`Bash(rm *)` / `Read(secret)` 这类规则要匹配的是**命令本身**,
226
+ * 而 shell 允许在命令前放赋值(`IFS=x rm -rf x`)、分组符号(`( rm -rf x )`)、
227
+ * 关键字(`for …; do rm -rf x; done`)。这些都不改变「执行了什么命令」,却足以
228
+ * 让匹配器看不到 `rm`,于是 deny 规则被一个空格级的改写绕过。
229
+ *
230
+ * 只剥前导、可反复剥(`FOO=1 ! rm …`)。剥多了一律是过匹配,而 deny 规则的过
231
+ * 匹配是安全方向。关键字自己的裸 flag 也一并剥掉(`time -p rm …` 里的 `-p`),
232
+ * 否则关键字被剥走后会剩下 `-p rm …`,仍然看不见 `rm`。
233
+ *
234
+ * 不剥尾随符号(`rm -rf x )` 里的 `)`):`wildcardMatch` 的 `*` 已经吃掉它。
235
+ */
236
+ export function stripLeadingShellNoise(segment: string): string {
237
+ let tokens = segment.trim().split(/\s+/).filter(Boolean)
238
+ let stripped = false
239
+ let skipFlags = false
240
+ for (;;) {
241
+ const head = tokens[0]
242
+ if (!head) break
243
+ if (skipFlags && /^--?[A-Za-z]/.test(head)) {
244
+ tokens = tokens.slice(1)
245
+ stripped = true
246
+ continue
247
+ }
248
+ skipFlags = false
249
+ const bare = head.replace(/^[({!]+/, '') // `(!` 这类连写
250
+ if (bare !== head) {
251
+ tokens = bare ? [bare, ...tokens.slice(1)] : tokens.slice(1)
252
+ stripped = true
253
+ continue
254
+ }
255
+ if (LEADING_SHELL_PUNCT.has(head) || LEADING_ASSIGNMENT_RE.test(head)) {
256
+ tokens = tokens.slice(1)
257
+ stripped = true
258
+ continue
259
+ }
260
+ if (LEADING_SHELL_KEYWORDS.has(head)) {
261
+ tokens = tokens.slice(1)
262
+ stripped = true
263
+ skipFlags = true // `time -p rm …` —— 关键字自己的裸 flag 不是命令
264
+ continue
265
+ }
266
+ break
267
+ }
268
+ return stripped ? tokens.join(' ') : segment
269
+ }
270
+
196
271
  function uniq(items: string[]): string[] {
197
272
  return [...new Set(items)]
198
273
  }
@@ -203,6 +278,29 @@ export interface BashFileAccess {
203
278
  write: string[]
204
279
  }
205
280
 
281
+ /**
282
+ * 展开候选路径里**已知**的变量:前导 `~`、`$HOME`/`${HOME}`。
283
+ *
284
+ * 为什么需要:规则里写的是绝对路径(`Read(/Users/me/.ssh/id_rsa)`),而用户敲的
285
+ * 是同一个文件的另一种拼法(`cat ~/.ssh/id_rsa`)—— 不展开即等于放行。展开后
286
+ * 两种拼法落到同一个字符串上,绝对路径形与路径通配形(如「.ssh 下任意文件」)
287
+ * 规则都能命中。
288
+ *
289
+ * **只展开 HOME**。`$FOO` 这类未知变量原样保留:展开它需要求值环境,静默展开成
290
+ * 空串会让 `/x/$FOO/y` 变成 `/x//y` —— 那是**新增**一个漏判方向,比不展开更坏。
291
+ * `$PWD` 同理需要 cwd,而 `matchBashRule` 的调用点拿不到 cwd,故未覆盖。这两条
292
+ * 都是本函数已知的边界,不是遗漏。
293
+ *
294
+ * `~` 只在**开头**展开(`a/~/b` 里的 `~` 是普通字符,shell 也不展开它)。
295
+ */
296
+ export function expandKnownPathVars(p: string): string {
297
+ const home = homedir() // 每次取,不在模块加载时绑定(HOME 可能被测试隔离改写)
298
+ let out = p
299
+ if (out === '~') out = home
300
+ else if (out.startsWith('~/')) out = join(home, out.slice(2))
301
+ return out.replace(/\$\{?HOME\}?/g, home)
302
+ }
303
+
206
304
  /**
207
305
  * Extract the file paths a Bash command reads or writes, so Read()/Write()/
208
306
  * Edit() deny rules also apply to Bash (not just the Read/Write/Edit tools).
@@ -231,7 +329,9 @@ export function extractBashFileAccess(command: string): BashFileAccess {
231
329
  // recursing into `$(...)` / backtick substitutions.
232
330
  scanReaderWriterCommands(command, read, write)
233
331
 
234
- return { read: uniq(read), write: uniq(write) }
332
+ // 展开放在出口这一处,而不是每个 push 点 —— 重定向与读/写命令、以及它们的
333
+ // 递归内层都汇进这两个数组,一处展开即全覆盖。
334
+ return { read: uniq(read.map(expandKnownPathVars)), write: uniq(write.map(expandKnownPathVars)) }
235
335
  }
236
336
 
237
337
  /** Extract the inner commands of `$(...)` and backtick substitutions. */
@@ -244,6 +344,12 @@ function extractSubstitutions(command: string): string[] {
244
344
  while ((m = dollarParen.exec(command)) !== null) inners.push(m[1]!)
245
345
  const backtick = /`([^`]*)`/g
246
346
  while ((m = backtick.exec(command)) !== null) inners.push(m[1]!)
347
+ // 进程替换 `<(cmd)` / `>(cmd)`:`cat <(cat secret)` 里读 secret 的是里层的
348
+ // `cat`,外层只是把它的 stdout 当成一个文件名。参数排除 `<>` 是因为
349
+ // `<(cat secret)` 的捕获若允许 `>`,遇到 `>(...)` 形态会被提前截断;非嵌套组
350
+ // 由调用方的递归处理。
351
+ const procSub = /[<>]\(([^()<>]*)\)/g
352
+ while ((m = procSub.exec(command)) !== null) inners.push(m[1]!)
247
353
  return inners
248
354
  }
249
355
 
@@ -261,6 +367,12 @@ function flattenCommand(command: string, depth = 0): string[] {
261
367
  out.push(seg)
262
368
  const stripped = stripPrefixCommand(seg)
263
369
  if (stripped !== seg) out.push(stripped)
370
+ // 剥掉前导噪声后的形态也要参与匹配:`IFS=x rm -rf x` / `( rm -rf x )` /
371
+ // `for …; do rm -rf x; done` 执行的仍是 `rm`,规则必须看得见它。同一个函数
372
+ // 也被 scanReaderWriterCommands 用(Read/Write/Edit 桥接那条路径)—— 两条
373
+ // 路径共用一份归一化,只接一条就是只修一半。
374
+ const denoised = stripLeadingShellNoise(seg)
375
+ if (denoised !== seg) out.push(denoised)
264
376
  for (const inner of extractSubstitutions(seg)) {
265
377
  out.push(...flattenCommand(inner, depth + 1))
266
378
  }
@@ -303,7 +415,10 @@ function effectiveCommand(tokens: string[]): {
303
415
  if (name === 'env') {
304
416
  while (i < tokens.length && tokens[i]!.includes('=')) i++ // `VAR=value` assignments
305
417
  }
306
- if (name === 'timeout') i++ // positional duration
418
+ // `timeout` 的位置参数是 duration,**不是**必然存在:`timeout 5 cmd` 有,
419
+ // `timeout --preserve-status cmd` 没有。无条件 `i++` 会把后者真正的命令
420
+ // (`cat`)当成 duration 吃掉,基命令退化成它的第一个参数。
421
+ if (name === 'timeout' && i < tokens.length && TIMEOUT_DURATION_RE.test(tokens[i]!)) i++
307
422
  // `eval`'s remaining arguments are themselves a command line, and must be
308
423
  // captured here rather than via the SHELL_COMMANDS branch below: `eval "cat
309
424
  // secret"` tokenizes to ['eval', '"cat', 'secret"'], so the base degrades to
@@ -388,7 +503,10 @@ function scanReaderWriterCommands(
388
503
  depth = 0,
389
504
  ): void {
390
505
  for (const seg of splitShellSegments(command)) {
391
- const tokens = seg.split(/\s+/).filter(Boolean)
506
+ // 先剥前导噪声再 tokenize:`IFS=x cat secret` / `! cat secret` /
507
+ // `time -p cat secret` 读的是同一个文件,Read() 规则必须看得见 `cat`。
508
+ // 与 flattenCommand 共用 stripLeadingShellNoise —— 两条路径一份归一化。
509
+ const tokens = stripLeadingShellNoise(seg).split(/\s+/).filter(Boolean)
392
510
  if (tokens.length > 0) {
393
511
  const { base, args, payload } = effectiveCommand(tokens)
394
512
  if (READER_COMMANDS.has(base)) {
@@ -418,6 +536,13 @@ function scanReaderWriterCommands(
418
536
  }
419
537
  }
420
538
 
539
+ /**
540
+ * `matchBashRule` 真正会按参数匹配工具名的集合。**与 matchBashRule 的分支一一
541
+ * 对应** —— 那里 `return false` 的工具,参数化规则在 validateRulePattern 里必须
542
+ * 被拒(否则规则永远不命中,是静默空防护)。改一边必须改另一边。
543
+ */
544
+ const PARAMETERISED_TOOLS = new Set(['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob'])
545
+
421
546
  // Match a tool(parameter) rule against an actual tool call.
422
547
  //
423
548
  // Pattern formats:
@@ -432,6 +557,7 @@ export function matchBashRule(
432
557
  pattern: string,
433
558
  toolName: string,
434
559
  toolInput: Record<string, unknown>,
560
+ segmentMode: 'any' | 'all' = 'any',
435
561
  ): boolean {
436
562
  // Check if pattern has a parenthesized sub-pattern
437
563
  const parenMatch = pattern.match(/^(\w+)\((.+)\)$/)
@@ -442,6 +568,20 @@ export function matchBashRule(
442
568
 
443
569
  const [, baseTool, subPattern] = parenMatch
444
570
 
571
+ // How a *compound* command is judged when only some parts match:
572
+ // 'any' (deny / ask) — one matching part is enough. Deliberately wide: a
573
+ // deny rule that misses a part is a hole, so `foo && rm -rf /` must be
574
+ // caught by `Bash(rm *)`.
575
+ // 'all' (allow) — every part must match. An allow rule is a *grant*, and
576
+ // granting on one matching part hands over the whole compound command:
577
+ // `Bash(git:*)` plus `git status && rm -rf ./src` used to return
578
+ // `bypass` with no prompt at all.
579
+ // The `length > 0` guard is load-bearing: `[].every()` is `true`, so a
580
+ // command with no matchable segment (or no extracted file access) would
581
+ // otherwise satisfy **any** allow rule — a fail-open of its own.
582
+ const qualifies = (items: string[], match: (s: string) => boolean): boolean =>
583
+ segmentMode === 'all' ? items.length > 0 && items.every(match) : items.some(match)
584
+
445
585
  // A Read/Write/Edit rule must also refuse a Bash command that touches the
446
586
  // same file (via a reader/editor command or a redirect), not only the
447
587
  // Read/Write/Edit tool itself. Otherwise `cat .git-credentials` bypasses a
@@ -450,7 +590,7 @@ export function matchBashRule(
450
590
  const cmd = String(toolInput.command || '')
451
591
  const access = extractBashFileAccess(cmd)
452
592
  const paths = baseTool === 'Read' ? access.read : access.write
453
- return paths.some((p) => matchPath(p, subPattern!))
593
+ return qualifies(paths, (p) => matchPath(p, subPattern!))
454
594
  }
455
595
 
456
596
  if (toolName !== baseTool!) return false
@@ -460,7 +600,7 @@ export function matchBashRule(
460
600
  // `$(...)`/backtick substitution, so `Bash(rm *)` catches `x=$(rm -rf ~)`).
461
601
  if (baseTool === 'Bash') {
462
602
  const cmd = String(toolInput.command || '')
463
- return flattenCommand(cmd).some((seg) => wildcardMatch(subPattern!, seg))
603
+ return qualifies(flattenCommand(cmd), (seg) => wildcardMatch(subPattern!, seg))
464
604
  }
465
605
 
466
606
  // For Write/Edit/Read: match against the file_path with path-glob semantics.
@@ -500,6 +640,10 @@ export function wildcardMatch(pattern: string, input: string): boolean {
500
640
  * `Bash()`) is silently dead today — this returns a human-readable reason so
501
641
  * the caller can report it as an invalid setting instead of ignoring it.
502
642
  *
643
+ * 校验的是**结构 + 工具名**(后者见 PARAMETERISED_TOOLS)。**不**校验子模式本身
644
+ * 能否匹配任何东西 —— `Bash(zzz *)` 合法、装得上、只是不会命中,那是用户自己的
645
+ * 选择,不是配置错误。
646
+ *
503
647
  * Returns null when the pattern is valid, or a reason string when malformed.
504
648
  */
505
649
  export function validateRulePattern(pattern: string): string | null {
@@ -510,9 +654,16 @@ export function validateRulePattern(pattern: string): string | null {
510
654
  // Empty parameter: `Bash()` or `Bash( )`
511
655
  if (/\(\s*\)$/.test(pattern)) return 'empty parameter'
512
656
  // Must be exactly `ToolName(param)` with nothing before or after.
513
- if (!/^(\w+)\((.+)\)$/.test(pattern)) {
657
+ const shape = pattern.match(/^(\w+)\((.+)\)$/)
658
+ if (!shape) {
514
659
  return 'unexpected text after the closing parenthesis'
515
660
  }
661
+ // 工具名必须落在 matchBashRule 真正会按参数匹配的那一集合里,否则这条规则是
662
+ // **静默空防护**:语法合法、装得上、永远不命中,而用户以为配了保护。
663
+ const tool = shape[1]!
664
+ if (!PARAMETERISED_TOOLS.has(tool)) {
665
+ return `parameterised rules are not supported for tool "${tool}" (it would never match)`
666
+ }
516
667
  return null
517
668
  }
518
669