@miphamai/cli 0.81.7 → 0.81.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Mipham Code v0.81.6
1
+ # Mipham Code v0.81.7
2
2
 
3
3
  **Multi-model open-core intelligent coding terminal** — 12 AI providers, 137 commands, 31 tools, 28 skills + marketplace, self-update. AI-assisted code generation, security auditing, MCP protocol, and extensible skills — in a single CLI.
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.81.7",
3
+ "version": "0.81.8",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,47 +1,95 @@
1
- import {
2
- readFileSync,
3
- writeFileSync,
4
- existsSync,
5
- mkdirSync,
6
- renameSync,
7
- copyFileSync,
8
- unlinkSync,
9
- } from 'node:fs'
1
+ import { readFileSync, existsSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'node:fs'
10
2
  import { join } from 'node:path'
3
+ import { atomicWriteFileSync } from '../shared/atomic-write'
11
4
  import type { ArtifactManifest, ArtifactEntry } from '../shared/types'
12
5
 
6
+ function emptyManifest(): ArtifactManifest {
7
+ return { version: 1, artifacts: [] }
8
+ }
9
+
13
10
  /**
14
11
  * Read the artifact manifest from disk, or return an empty one if it doesn't exist.
12
+ *
13
+ * An unreadable index also yields an empty manifest — the callers here only *show*
14
+ * artifacts (gallery, `/artifact list`), and showing none beats throwing at them.
15
+ * Writers must not go through this path: see `readManifestForUpdate`.
15
16
  */
16
17
  export function readManifest(dir: string): ArtifactManifest {
17
18
  const path = join(dir, 'index.json')
18
19
  if (!existsSync(path)) {
19
- return { version: 1, artifacts: [] }
20
+ return emptyManifest()
20
21
  }
21
22
  try {
22
23
  return JSON.parse(readFileSync(path, 'utf-8'))
23
24
  } catch {
24
- return { version: 1, artifacts: [] }
25
+ return emptyManifest()
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Read the manifest for a caller that is about to write it back.
31
+ *
32
+ * The difference from `readManifest` is what happens when the file exists but does
33
+ * not parse: a writer must not treat that as "empty", because writing back an
34
+ * empty manifest replaces every other artifact's entry with nothing — the files
35
+ * stay on disk, but the gallery and `/artifact list` lose them. So the unreadable
36
+ * file is renamed aside (bytes kept, recoverable by hand) and reported, and the
37
+ * caller decides what to tell the user.
38
+ */
39
+ function readManifestForUpdate(dir: string): {
40
+ manifest: ArtifactManifest
41
+ quarantined?: string
42
+ } {
43
+ const path = join(dir, 'index.json')
44
+ if (!existsSync(path)) return { manifest: emptyManifest() }
45
+ try {
46
+ return { manifest: JSON.parse(readFileSync(path, 'utf-8')) }
47
+ } catch {
48
+ const quarantined = `${path}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`
49
+ // If this rename fails we let it throw: the alternative is overwriting the
50
+ // only copy of the index with a manifest built from nothing.
51
+ renameSync(path, quarantined)
52
+ return { manifest: emptyManifest(), quarantined }
25
53
  }
26
54
  }
27
55
 
28
56
  /**
29
57
  * Write the manifest to disk, creating parent directories as needed.
58
+ *
59
+ * Atomic: a crash mid-write used to leave a truncated `index.json`, which is
60
+ * exactly the corruption `readManifestForUpdate` then has to quarantine.
30
61
  */
31
62
  export function writeManifest(dir: string, manifest: ArtifactManifest): void {
32
63
  mkdirSync(dir, { recursive: true })
33
- writeFileSync(join(dir, 'index.json'), JSON.stringify(manifest, null, 2), 'utf-8')
64
+ atomicWriteFileSync(join(dir, 'index.json'), JSON.stringify(manifest, null, 2), {
65
+ mode: 0o644,
66
+ })
34
67
  }
35
68
 
36
69
  /**
37
70
  * Add an entry to the manifest and persist it.
38
- * If an entry with the same name already exists, it is replaced.
71
+ *
72
+ * An entry is identified by `name` **and** `sessionId`, matching how the tool
73
+ * looks one up: the manifest is a single global `index.json` holding every
74
+ * session's artifacts, so keying on the name alone made two sessions publishing
75
+ * the same name overwrite each other's entry — the loser's file stayed on disk
76
+ * but vanished from the index.
77
+ *
78
+ * Returns the written manifest plus, when the previous index was unreadable, the
79
+ * path its bytes were moved to — the caller is expected to say so rather than
80
+ * let an artifact appear to publish cleanly over a lost index.
39
81
  */
40
- export function addToManifest(dir: string, entry: ArtifactEntry, port?: number): ArtifactManifest {
41
- const manifest = readManifest(dir)
82
+ export function addToManifest(
83
+ dir: string,
84
+ entry: ArtifactEntry,
85
+ port?: number,
86
+ ): { manifest: ArtifactManifest; quarantined?: string } {
87
+ const { manifest, quarantined } = readManifestForUpdate(dir)
42
88
  if (port !== undefined) manifest.port = port
43
89
 
44
- const idx = manifest.artifacts.findIndex((a) => a.name === entry.name)
90
+ const idx = manifest.artifacts.findIndex(
91
+ (a) => a.name === entry.name && a.sessionId === entry.sessionId,
92
+ )
45
93
  if (idx >= 0) {
46
94
  manifest.artifacts[idx] = entry
47
95
  } else {
@@ -49,7 +97,7 @@ export function addToManifest(dir: string, entry: ArtifactEntry, port?: number):
49
97
  }
50
98
 
51
99
  writeManifest(dir, manifest)
52
- return manifest
100
+ return { manifest, quarantined }
53
101
  }
54
102
 
55
103
  /**
@@ -64,31 +112,39 @@ export function getSessionArtifacts(dir: string, sessionId: string): ArtifactEnt
64
112
  * Archive an existing artifact file by renaming it with a version tag.
65
113
  * e.g. dashboard.html → dashboard.v1.html, dashboard.v1.html → dashboard.v2.html.
66
114
  *
67
- * Returns the version tag assigned to the archived file.
115
+ * Returns the version tag assigned to the archived file, or `undefined` when
116
+ * there was nothing to archive — in which case the manifest is left untouched.
117
+ *
118
+ * 「没归档就什么都不记」是刻意的:源文件找不到时照样推进版本号、往 `versions` 里
119
+ * 塞一个标签,等于在 manifest 里留一版磁盘上并不存在的版本。走到那条路并不难
120
+ * —— 条目按 name 找、文件按 `<session>/<name><ext>` 找,把同一个名字从 html 改
121
+ * 成 svg 就错开了。宁可少记一版,也不能记一版假的。
68
122
  */
69
- export function archiveVersion(dir: string, entry: ArtifactEntry): string {
70
- const manifest = readManifest(dir)
71
- const versionCount = (entry.versionCount || 1) + 1
123
+ export function archiveVersion(dir: string, entry: ArtifactEntry): string | undefined {
72
124
  const ext = entry.type === 'svg' ? '.svg' : '.html'
73
- const versionTag = `v${versionCount}`
74
-
75
- // Rename the current file to a versioned copy
76
125
  const baseName = entry.name
77
126
  const currentPath = join(dir, entry.sessionId, `${baseName}${ext}`)
127
+
128
+ if (!existsSync(currentPath)) return undefined
129
+
130
+ const versionCount = (entry.versionCount || 1) + 1
131
+ const versionTag = `v${versionCount}`
78
132
  const archivedPath = join(dir, entry.sessionId, `${baseName}.${versionTag}${ext}`)
79
133
 
80
- if (existsSync(currentPath)) {
81
- try {
82
- renameSync(currentPath, archivedPath)
83
- } catch {
84
- // If rename fails (e.g. cross-device), copy instead
85
- copyFileSync(currentPath, archivedPath)
86
- unlinkSync(currentPath)
87
- }
134
+ try {
135
+ renameSync(currentPath, archivedPath)
136
+ } catch {
137
+ // If rename fails (e.g. cross-device), copy instead
138
+ copyFileSync(currentPath, archivedPath)
139
+ unlinkSync(currentPath)
88
140
  }
89
141
 
90
- // Update manifest entry
91
- const artifact = manifest.artifacts.find((a) => a.name === entry.name)
142
+ // Update manifest entry — keyed on name *and* session, like every other
143
+ // lookup here: a same-named artifact in another session is a different one.
144
+ const manifest = readManifest(dir)
145
+ const artifact = manifest.artifacts.find(
146
+ (a) => a.name === entry.name && a.sessionId === entry.sessionId,
147
+ )
92
148
  if (artifact) {
93
149
  const versions = artifact.versions || ['v1']
94
150
  versions.push(versionTag)
@@ -0,0 +1,19 @@
1
+ import { join } from 'node:path'
2
+ import { ARTIFACTS_DIR, MIPHAM_DIR } from '../shared/constants'
3
+
4
+ /**
5
+ * The one place that decides *where on disk an artifact lives*.
6
+ *
7
+ * Three sites need to agree on this: the Artifact tool (writes the file and the
8
+ * manifest), the ArtifactServer (serves files and builds the gallery from the
9
+ * manifest rooted here), and `/artifact list` (reads the manifest). Each of them
10
+ * computing the path itself is what produced the original bug: the tool wrote
11
+ * `<cwd>/artifacts/...` while the server served `<cwd>/.mipham/artifacts/...`,
12
+ * so the URL the tool reported was a guaranteed 404 — the two halves never met.
13
+ *
14
+ * Keeping them joined here means a change moves all three together; the guard
15
+ * test asserts the *behaviour* (the reported URL resolves), not the literal.
16
+ */
17
+ export function artifactsRoot(cwd: string): string {
18
+ return join(cwd, MIPHAM_DIR, ARTIFACTS_DIR)
19
+ }
@@ -1,9 +1,9 @@
1
1
  import { createServer, type Server } from 'node:http'
2
- import { createReadStream, existsSync, statSync } from 'node:fs'
2
+ import type { Socket } from 'node:net'
3
+ import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
3
4
  import { join, normalize, extname } from 'node:path'
4
5
  import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
5
6
  import { readManifest } from './manifest'
6
- import { ArtifactVersioning } from './versioning'
7
7
  import type { ArtifactEntry } from '../shared/types'
8
8
  import { getMetrics } from '../core/metrics'
9
9
 
@@ -29,12 +29,11 @@ export class ArtifactServer {
29
29
  private started = false
30
30
  private sseClients: SseClient[] = []
31
31
  private sseIdCounter = 0
32
- private versioning: ArtifactVersioning
32
+ private sockets = new Set<Socket>()
33
33
 
34
34
  constructor(artifactsDir: string, preferredPort: number) {
35
35
  this.artifactsDir = artifactsDir
36
36
  this.port = preferredPort
37
- this.versioning = new ArtifactVersioning(artifactsDir)
38
37
  }
39
38
 
40
39
  /** Notify all connected SSE clients to reload. Called after artifact changes. */
@@ -82,6 +81,12 @@ export class ArtifactServer {
82
81
  this.server = null
83
82
  this.started = false
84
83
  }
84
+
85
+ // `close()` only stops *accepting* — sockets already established (a browser's
86
+ // keep-alive, an SSE stream) stay open and keep being served, so a "stopped"
87
+ // server answers on the old port until the client hangs up. Destroy them.
88
+ for (const socket of this.sockets) socket.destroy()
89
+ this.sockets.clear()
85
90
  }
86
91
 
87
92
  getPort(): number {
@@ -114,6 +119,10 @@ export class ArtifactServer {
114
119
  const srv = createServer((req, res) => {
115
120
  this.handleRequest(req, res)
116
121
  })
122
+ srv.on('connection', (socket) => {
123
+ this.sockets.add(socket)
124
+ socket.on('close', () => this.sockets.delete(socket))
125
+ })
117
126
  srv.on('error', reject)
118
127
  srv.listen(port, () => {
119
128
  this.server = srv
@@ -216,8 +225,31 @@ export class ArtifactServer {
216
225
  })
217
226
  }
218
227
 
219
- /** Per-artifact SSE stream: pushes content updates to connected browsers every 500ms. */
228
+ /**
229
+ * Per-artifact SSE stream: pushes the artifact's content to connected browsers
230
+ * every 500ms, so a page can follow an artifact the AI rewrites in place.
231
+ *
232
+ * Resolves the file through `resolveFile` — the same coordinate the gallery
233
+ * links to and the same traversal guard the static path uses — and 404s when no
234
+ * artifact carries that name, instead of holding a stream open on a file that
235
+ * does not exist.
236
+ */
220
237
  private handleNameSse(name: string, res: any): void {
238
+ const entry = readManifest(this.artifactsDir).artifacts.find((a) => a.name === name)
239
+ if (!entry) {
240
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
241
+ res.end(`No artifact named "${name}"`)
242
+ return
243
+ }
244
+
245
+ const ext = entry.type === 'svg' ? '.svg' : '.html'
246
+ const { filePath, error, status } = this.resolveFile(`/${entry.sessionId}/${entry.name}${ext}`)
247
+ if (error) {
248
+ res.writeHead(status || 404, { 'Content-Type': 'text/plain' })
249
+ res.end(error)
250
+ return
251
+ }
252
+
221
253
  res.writeHead(200, {
222
254
  'Content-Type': 'text/event-stream',
223
255
  'Cache-Control': 'no-cache',
@@ -225,10 +257,18 @@ export class ArtifactServer {
225
257
  'Access-Control-Allow-Origin': '*',
226
258
  })
227
259
 
260
+ let last: string | null = null
228
261
  const interval = setInterval(() => {
229
- const content = this.versioning.getVersion(name)
230
- if (content) {
231
- res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`)
262
+ try {
263
+ const content = readFileSync(filePath, 'utf-8')
264
+ // Only on change: without this the stream re-sends the whole file every
265
+ // 500ms per client for as long as the tab stays open.
266
+ if (content !== last) {
267
+ last = content
268
+ res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`)
269
+ }
270
+ } catch {
271
+ // Momentarily absent (archiving renames it) — skip this tick, try the next.
232
272
  }
233
273
  }, 500)
234
274
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
+ import { atomicWriteFileSync } from '../shared/atomic-write'
4
5
  import { saveProviderApiKey } from './loader'
5
6
 
6
7
  const MIPHAM_HOME = join(homedir(), '.mipham')
@@ -39,14 +40,12 @@ function loadKeys(): KeysData {
39
40
 
40
41
  function saveKeys(data: KeysData): void {
41
42
  mkdirSync(dirname(KEYS_FILE), { recursive: true })
42
- const tmp = KEYS_FILE + '.tmp'
43
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
44
- writeFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
45
- try {
46
- chmodSync(KEYS_FILE, 0o600)
47
- } catch {
48
- // chmod on Windows is a no-op
49
- }
43
+ // 从前这里「转了一半」:先写一个固定名的 `.tmp`,**然后不 rename、直接再写一遍
44
+ // 目标**。留在磁盘上的 `.tmp` 是废物,目标仍然非原子 —— 并发 `/keys rotate` 撞进
45
+ // 同一个临时名,或写到一半被打断,`loadKeys` 把不可解析的 JSON 吞成 `{}`
46
+ // (见上面的 catch)⇒ 全部轮换元数据静默消失。atomicWriteFileSync 自己写唯一名
47
+ // 临时文件再 rename,两者一起解决。
48
+ atomicWriteFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
50
49
  }
51
50
 
52
51
  function daysSince(iso: string): number {
@@ -5,9 +5,10 @@
5
5
  * NOT for config.yml settings — those belong in the YAML config system.
6
6
  * NOT for secrets — this file is plain JSON, not encrypted.
7
7
  */
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
8
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs'
9
9
  import { join } from 'node:path'
10
10
  import { homedir } from 'node:os'
11
+ import { atomicWriteFileSync } from '../shared/atomic-write'
11
12
 
12
13
  const PREFS_PATH = join(homedir(), '.mipham', 'preferences.json')
13
14
 
@@ -27,7 +28,9 @@ function writePrefs(prefs: Record<string, string>): void {
27
28
  try {
28
29
  const dir = join(homedir(), '.mipham')
29
30
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
30
- writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600, encoding: 'utf-8' })
31
+ // 原子写:裸 writeFileSync 原地截断,崩在写中途就留下一份不可解析的文件,
32
+ // 而 readPrefs 把不可解析吞成「空」⇒ **全部**偏好静默消失(不是丢一项)。
33
+ atomicWriteFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600 })
31
34
  } catch {
32
35
  // best-effort; never crash because preferences failed to save
33
36
  }
@@ -9,9 +9,22 @@ import { computeNextFire } from './cron'
9
9
  import type { CronJob } from '../tools/scheduling/cron'
10
10
  import { readAllJobs, writeJob, deleteJobFile } from '../tools/scheduling/cron'
11
11
 
12
- /** Jobs whose nextFire is at or before `now`. Pure — separated for tests. */
13
- export function findDueJobs(jobs: CronJob[], now: Date): CronJob[] {
14
- return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime())
12
+ /**
13
+ * Whether a job belongs to `cwd`.
14
+ *
15
+ * A job with no `cwd` is from a file written before jobs carried one; it matches
16
+ * anywhere so an existing user's schedule keeps firing instead of going silent.
17
+ * `cwd === undefined` means the caller did not ask for scoping at all (the pure
18
+ * helpers' existing callers), so nothing is filtered.
19
+ */
20
+ function matchesCwd(job: CronJob, cwd?: string): boolean {
21
+ if (job.cwd === undefined || cwd === undefined) return true
22
+ return job.cwd === cwd
23
+ }
24
+
25
+ /** Jobs whose nextFire is at or before `now` — and which belong to `cwd`. */
26
+ export function findDueJobs(jobs: CronJob[], now: Date, cwd?: string): CronJob[] {
27
+ return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime() && matchesCwd(j, cwd))
15
28
  }
16
29
 
17
30
  /** Next state after firing a due job: recurring advances; one-shot → null (delete). */
@@ -24,9 +37,20 @@ export function advanceJob(job: CronJob, now: Date): CronJob | null {
24
37
  }
25
38
  }
26
39
 
27
- /** Read due jobs, enqueue their prompts, and advance/delete. Returns fired count. */
28
- export function checkCronJobs(enqueue: (prompt: string) => void, now = new Date()): number {
29
- const due = findDueJobs(readAllJobs(), now)
40
+ /**
41
+ * Read due jobs, enqueue their prompts, and advance/delete. Returns fired count.
42
+ *
43
+ * `cwd` defaults to the process's working directory — the same source
44
+ * `ToolContext.cwd` comes from — because the enqueued prompt lands in *this*
45
+ * session and is executed here. Without the filter, a schedule created in one
46
+ * project would be run by whatever session happened to be open in another.
47
+ */
48
+ export function checkCronJobs(
49
+ enqueue: (prompt: string) => void,
50
+ now = new Date(),
51
+ cwd = process.cwd(),
52
+ ): number {
53
+ const due = findDueJobs(readAllJobs(), now, cwd)
30
54
  for (const job of due) {
31
55
  enqueue(job.prompt)
32
56
  const next = advanceJob(job, now)
@@ -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:
@@ -500,6 +625,10 @@ export function wildcardMatch(pattern: string, input: string): boolean {
500
625
  * `Bash()`) is silently dead today — this returns a human-readable reason so
501
626
  * the caller can report it as an invalid setting instead of ignoring it.
502
627
  *
628
+ * 校验的是**结构 + 工具名**(后者见 PARAMETERISED_TOOLS)。**不**校验子模式本身
629
+ * 能否匹配任何东西 —— `Bash(zzz *)` 合法、装得上、只是不会命中,那是用户自己的
630
+ * 选择,不是配置错误。
631
+ *
503
632
  * Returns null when the pattern is valid, or a reason string when malformed.
504
633
  */
505
634
  export function validateRulePattern(pattern: string): string | null {
@@ -510,9 +639,16 @@ export function validateRulePattern(pattern: string): string | null {
510
639
  // Empty parameter: `Bash()` or `Bash( )`
511
640
  if (/\(\s*\)$/.test(pattern)) return 'empty parameter'
512
641
  // Must be exactly `ToolName(param)` with nothing before or after.
513
- if (!/^(\w+)\((.+)\)$/.test(pattern)) {
642
+ const shape = pattern.match(/^(\w+)\((.+)\)$/)
643
+ if (!shape) {
514
644
  return 'unexpected text after the closing parenthesis'
515
645
  }
646
+ // 工具名必须落在 matchBashRule 真正会按参数匹配的那一集合里,否则这条规则是
647
+ // **静默空防护**:语法合法、装得上、永远不命中,而用户以为配了保护。
648
+ const tool = shape[1]!
649
+ if (!PARAMETERISED_TOOLS.has(tool)) {
650
+ return `parameterised rules are not supported for tool "${tool}" (it would never match)`
651
+ }
516
652
  return null
517
653
  }
518
654