@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
@@ -1,3 +1,21 @@
1
+ /**
2
+ * SecurityGate — 判定函数集合。
3
+ *
4
+ * ⚠️ **本文件不是一个已生效的安全门。** 它是一个**判定函数的库**,其中只有一部分
5
+ * 被接进生产路径。别把「文件被 import」读成「这些检查在生产里跑」:
6
+ *
7
+ * - `redactCredentialLeak` — **已接线**(`core/behavior-tasks.ts`、
8
+ * `core/credential-masker/output-scrub.ts`)。
9
+ * - 其余三个 `check*` 方法 — **生产零调用点**,唯一消费者是
10
+ * `test/security/penetration/`。因此 CI 的 `penetration-test` job 全绿只说明
11
+ * **这些判定函数被自己测过**,不说明生产有这三道防线。三条各自的去路(为什么不接、
12
+ * 生产另有哪条防线)登记在
13
+ * `test/integrity/unwired-disposition.test.ts` 的 `KEPT_UNWIRED_METHODS` 里,
14
+ * 由机器两向强制(存在 + 生产零引用)。
15
+ *
16
+ * 想让某条真正生效时:先读那条登记的理由,再决定是接线还是改判据 —— 直接 `import`
17
+ * 进来调是最容易的一步,也是最容易多出一道更弱、更难维护、判据还与被取代者不一致的门。
18
+ */
1
19
  export interface GateResult {
2
20
  blocked: boolean
3
21
  reason?: string
@@ -7,6 +7,24 @@ import { realpathSync, existsSync } from 'node:fs'
7
7
  */
8
8
  const BLOCKED_PATHS = ['/etc', '/proc', '/sys', '/dev', '/boot', '/root']
9
9
 
10
+ /**
11
+ * The same list in canonical form.
12
+ *
13
+ * The check below compares against an already-resolved path, so the entries
14
+ * must be resolved too: on macOS `/etc` is a symlink to `/private/etc`, and
15
+ * comparing `/private/etc/x` against the literal `/etc` never matched — the
16
+ * check was dead there. Entries that don't exist yet keep their literal form
17
+ * (there is nothing to resolve, and a later `realpathSync` of the same path
18
+ * cannot produce a different spelling).
19
+ */
20
+ const BLOCKED_CANONICAL: string[] = BLOCKED_PATHS.map((p) => {
21
+ try {
22
+ return existsSync(p) ? realpathSync(p) : p
23
+ } catch {
24
+ return p
25
+ }
26
+ })
27
+
10
28
  /**
11
29
  * Windows UNC paths and NT/Win32/DOS device-namespace prefixes.
12
30
  *
@@ -86,7 +104,7 @@ export function resolveSafe(cwd: string, inputPath: string): string {
86
104
  }
87
105
 
88
106
  // Check 2: must not target sensitive system directories
89
- for (const blocked of BLOCKED_PATHS) {
107
+ for (const blocked of BLOCKED_CANONICAL) {
90
108
  if (canonical === blocked || canonical.startsWith(blocked + '/')) {
91
109
  throw new Error(
92
110
  `Path rejected: "${inputPath}" resolves to a protected system directory (${blocked}).`,
@@ -113,8 +131,13 @@ function findExistingParent(p: string): string | undefined {
113
131
 
114
132
  /**
115
133
  * Check if `child` is within `parent` (or equal to it).
134
+ *
135
+ * Compares path segments, not string prefixes: `/a/b-evil` is NOT within
136
+ * `/a/b`. Both sides are expected to be resolved (no `..`, no trailing slash
137
+ * beyond the root). The trailing-slash normalization below is for callers that
138
+ * hand in an unresolved string such as `/proj/src/`.
116
139
  */
117
- function isWithin(child: string, parent: string): boolean {
140
+ export function isWithin(child: string, parent: string): boolean {
118
141
  // Normalize trailing slashes for comparison
119
142
  const c = child.endsWith('/') ? child.slice(0, -1) : child
120
143
  const p = parent.endsWith('/') ? parent.slice(0, -1) : parent
@@ -29,7 +29,42 @@ const KNOWN_COMMANDS = [
29
29
  'help',
30
30
  ]
31
31
 
32
- const KNOWN_FLAGS = ['--version', '-v', '-V', '--help', '-h', '--dump-config', '--safe-mode']
32
+ const KNOWN_FLAGS = [
33
+ '--version',
34
+ '-v',
35
+ '-V',
36
+ '--help',
37
+ '-h',
38
+ '--dump-config',
39
+ '--safe-mode',
40
+ '--resume',
41
+ ]
42
+
43
+ /**
44
+ * Flags that consume the **next** argument as their value. That value is not a
45
+ * command, so it must not go through the unknown-command check: with
46
+ * `mipham --resume "my session"` the old scan found the first token that didn't
47
+ * start with `-`, concluded the user had typed a command, and reported
48
+ * `Unknown command: mipham my session` — blaming the session name and never
49
+ * mentioning `--resume`, the one argument that was actually wrong.
50
+ */
51
+ const VALUE_FLAGS = ['--resume']
52
+
53
+ /**
54
+ * The first token that would be read as a command, skipping flags and the values
55
+ * of value-taking flags. `null` when there is none.
56
+ */
57
+ function firstPositional(args: string[]): string | null {
58
+ for (let i = 0; i < args.length; i++) {
59
+ const arg = args[i]!
60
+ if (arg.startsWith('-')) {
61
+ if (VALUE_FLAGS.includes(arg)) i++ // its value is not a command
62
+ continue
63
+ }
64
+ return arg
65
+ }
66
+ return null
67
+ }
33
68
 
34
69
  /** Levenshtein edit distance between two strings. */
35
70
  function levenshtein(a: string, b: string): number {
@@ -64,7 +99,7 @@ function closest(target: string, candidates: string[], maxDist = 3): string[] {
64
99
  * command is present, instead of falling through to the interactive CLI.
65
100
  */
66
101
  export function detectUnknownArgument(args: string[]): UnknownArgument | null {
67
- const firstArg = args.find((a) => !a.startsWith('-'))
102
+ const firstArg = firstPositional(args)
68
103
  if (firstArg && !KNOWN_COMMANDS.includes(firstArg)) {
69
104
  return { kind: 'command', arg: firstArg, suggestions: closest(firstArg, KNOWN_COMMANDS) }
70
105
  }
@@ -1,17 +1,40 @@
1
- import { writeFileSync, renameSync } from 'node:fs'
1
+ import { writeFileSync, renameSync, unlinkSync } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
2
3
 
3
4
  /**
4
- * Write a file atomically: write to a same-directory `.tmp` file, then rename
5
+ * Write a file atomically: write to a same-directory temp file, then rename
5
6
  * over the target. Same-filesystem rename is atomic, so a crash or kill
6
7
  * mid-write can never leave a truncated/corrupt file — readers see either the
7
8
  * old or the new content, never a partial write.
9
+ *
10
+ * The temp name carries the pid and a random suffix rather than being a fixed
11
+ * `path + '.tmp'`. Several callers write to *shared* locations (the telemetry
12
+ * queue on every process exit, skill usage, the CRSI ledger), so two sessions or
13
+ * daemon workers on one machine can be inside this function for the same path at
14
+ * the same time. With one shared temp name that interleaving loses a write (the
15
+ * first rename moves the *second* writer's content into place) and then throws
16
+ * ENOENT on the other writer's rename — in a function whose whole point is that
17
+ * the target is never observed half-written. Same directory is still required:
18
+ * rename is only atomic within a filesystem.
8
19
  */
9
20
  export function atomicWriteFileSync(
10
21
  path: string,
11
22
  content: string,
12
23
  options: { mode?: number } = {},
13
24
  ): void {
14
- const tmp = path + '.tmp'
15
- writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
16
- renameSync(tmp, path)
25
+ const tmp = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`
26
+ try {
27
+ writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
28
+ renameSync(tmp, path)
29
+ } catch (err) {
30
+ // Clean up on the failure path: a fixed temp name used to be overwritten by
31
+ // the next writer, but unique names mean every abandoned write would leave
32
+ // its own orphan forever — nothing else ever sweeps this directory.
33
+ try {
34
+ unlinkSync(tmp)
35
+ } catch {
36
+ // Already renamed away (or never created) — nothing to clean.
37
+ }
38
+ throw err
39
+ }
17
40
  }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.81.7' as const
12
+ export const PACKAGE_VERSION = '0.81.9' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -10,14 +10,39 @@
10
10
  * to ASCII equivalents for permission checks.
11
11
  */
12
12
 
13
- const DANGEROUS_UNICODE = /[​‌‍‎‏‪‫‬‭‮⁠⁦⁧⁨⁩]/g
13
+ /**
14
+ * Invisible/formatting code points that can hide content.
15
+ *
16
+ * Written as `\u{…}` escapes (hence the `u` flag) so the set is *auditable*: the
17
+ * same list spelled as literal characters is unreviewable in a diff, which is how
18
+ * the tag block (U+E0000–E007F) went missing while every neighbouring family was
19
+ * already covered.
20
+ *
21
+ * Deliberately **not** included — variation selectors (U+FE00–FE0F):
22
+ * - they are load-bearing in emoji (e.g. U+2764 U+FE0F), so stripping them
23
+ * visibly changes text;
24
+ * - `sanitizeParams` feeds `tool.execute` (`tools/validation.ts`), so the strip is
25
+ * applied to what tools *write to disk*, not only to what gets pattern-matched.
26
+ * Adding them here would silently rewrite file contents.
27
+ *
28
+ * The tag block (U+E0000–E007F) *is* included: it is invisible in itself, and the
29
+ * only sequences that use it (subdivision flags, e.g. U+1F3F4 + a tag run) degrade
30
+ * to the bare black flag — which renders the same.
31
+ */
32
+ const DANGEROUS_UNICODE =
33
+ /[\u{061C}\u{115F}-\u{1160}\u{180E}\u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{3164}\u{FEFF}\u{FFA0}\u{E0000}-\u{E007F}]/gu
14
34
 
15
35
  /**
16
36
  * Strip dangerous invisible Unicode characters from a string.
17
37
  * - Zero-width: U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+200E/F (LTR/RTL marks)
18
- * - Bidi controls: U+202A-E, U+2066-9
38
+ * - Bidi controls: U+202A-E, U+2066-9, U+061C (Arabic letter mark)
19
39
  * - Word joiner: U+2060
20
40
  * - BOM: U+FEFF
41
+ * - Invisible fillers: U+115F/1160 (Hangul choseong/jungseong), U+3164 (Hangul),
42
+ * U+FFA0 (halfwidth Hangul), U+180E (Mongolian vowel separator)
43
+ * - Tag characters: U+E0000-E007F (deprecated, invisible, hide arbitrary text)
44
+ *
45
+ * See `DANGEROUS_UNICODE` for which *adjacent* families are left in place, and why.
21
46
  */
22
47
  export function stripDangerousUnicode(input: string): string {
23
48
  if (!input) return input
@@ -138,6 +138,15 @@ export interface StreamChunk {
138
138
  inputTokens?: number
139
139
  /** API-reported output token count (type: 'usage'). */
140
140
  outputTokens?: number
141
+ /**
142
+ * The provider stopped because it hit the output token ceiling
143
+ * (OpenAI `finish_reason: 'length'` / Anthropic `stop_reason: 'max_tokens'`).
144
+ * Set **only when true** — absent on every normal stop, so the success path
145
+ * stays byte-identical. Without it a truncated turn is indistinguishable
146
+ * from a turn the model chose to end: the provider emits its terminal stop
147
+ * either way, and tool calls cut off mid-arguments are dropped silently.
148
+ */
149
+ truncated?: boolean
141
150
  }
142
151
 
143
152
  // ── Config Types ──
@@ -267,6 +276,14 @@ export interface HookDefinition {
267
276
 
268
277
  export interface HookContext {
269
278
  event: HookEvent
279
+ /**
280
+ * The workspace this invocation is for.
281
+ *
282
+ * Stamped by `HookEngine` from its own cwd; hooks read it out of stdin and run
283
+ * in it. It is the *session's* cwd, which is not `process.cwd()` in the daemon
284
+ * (many sessions, one process).
285
+ */
286
+ cwd?: string
270
287
  toolName?: string
271
288
  toolInput?: Record<string, unknown>
272
289
  toolResult?: ToolResult
@@ -26,10 +26,20 @@ const REGISTRIES = [
26
26
  export interface UpdateCheck {
27
27
  /** Current installed version */
28
28
  current: string
29
- /** Latest version on npm */
29
+ /** Latest version on npm — only meaningful when `checked` is true */
30
30
  latest: string
31
31
  /** Whether an update is available */
32
32
  available: boolean
33
+ /**
34
+ * Whether the registry was actually reached.
35
+ *
36
+ * `false` means `latest`/`available` carry **no information**: the check
37
+ * failed and `latest` is just `current`. Without this field the two states
38
+ * are the same value — "we asked, you're current" and "we couldn't ask" both
39
+ * read as `available: false`, which is how `/upgrade` came to print
40
+ * "Already up to date" while offline.
41
+ */
42
+ checked: boolean
33
43
  }
34
44
 
35
45
  /**
@@ -101,15 +111,19 @@ export function checkForUpdates(): UpdateCheck {
101
111
  const current = getCurrentVersion()
102
112
  let latest = current
103
113
  let available = false
114
+ let checked = false
104
115
 
105
116
  try {
106
117
  latest = fetchLatestVersion()
107
118
  available = compareVersions(latest, current) > 0
119
+ checked = true
108
120
  } catch {
109
- // If we can't reach npm, treat as up-to-date (don't alarm the user)
121
+ // Registry unreachable — stay quiet rather than alarm, but do **not** claim
122
+ // we are current: `checked: false` is what lets `/upgrade` say "couldn't
123
+ // check" instead of "Already up to date".
110
124
  }
111
125
 
112
- return { current, latest, available }
126
+ return { current, latest, available, checked }
113
127
  }
114
128
 
115
129
  /**
@@ -222,13 +236,16 @@ export async function checkForUpdatesAsync(): Promise<UpdateCheck> {
222
236
  const current: string = PACKAGE_VERSION
223
237
  let latest: string = current
224
238
  let available = false
239
+ let checked = false
225
240
  try {
226
241
  latest = await fetchLatestVersionAsync()
227
242
  available = compareVersions(latest, current) > 0
243
+ checked = true
228
244
  } catch {
229
- // offline → treat as up-to-date (don't alarm the user)
245
+ // offline → don't alarm the user, but don't report `available: false` as if
246
+ // it were an answer either (`checked` is what keeps the two apart)
230
247
  }
231
- return { current, latest, available }
248
+ return { current, latest, available, checked }
232
249
  }
233
250
 
234
251
  /**
@@ -88,6 +88,9 @@ export const agentTool: ToolDefinition = {
88
88
  type: agentType,
89
89
  agentDef,
90
90
  runInBackground,
91
+ // Hand the caller's services down: the sub-agent keeps running the same
92
+ // skills/agents/artifacts, and only the fields it owns are overridden.
93
+ toolContext: ctx,
91
94
  })
92
95
 
93
96
  // If background execution, also register in the task system for Task tool integration
@@ -1,7 +1,8 @@
1
1
  import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { ToolDefinition } from '../../shared/index.ts'
4
- import { ARTIFACTS_DIR, ARTIFACT_MAX_SIZE } from '../../shared/constants'
4
+ import { ARTIFACT_MAX_SIZE } from '../../shared/constants'
5
+ import { artifactsRoot } from '../../artifacts/paths'
5
6
  import { addToManifest, readManifest, archiveVersion } from '../../artifacts/manifest'
6
7
 
7
8
  const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/
@@ -57,8 +58,10 @@ export const artifactTool: ToolDefinition = {
57
58
  }
58
59
  }
59
60
 
60
- // Determine output paths
61
- const baseDir = join(ctx.cwd, ARTIFACTS_DIR)
61
+ // Determine output paths — the server's root, the manifest's home and this
62
+ // directory are one and the same; computing it here is what made the URL a
63
+ // guaranteed 404 before.
64
+ const baseDir = artifactsRoot(ctx.cwd)
62
65
  const sessionDir = join(baseDir, ctx.sessionId)
63
66
  mkdirSync(sessionDir, { recursive: true })
64
67
 
@@ -100,7 +103,7 @@ export const artifactTool: ToolDefinition = {
100
103
  const prev = manifestPre.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId)
101
104
  const versionCount = prev?.versionCount || (isUpdate ? 1 : undefined)
102
105
 
103
- addToManifest(
106
+ const { quarantined } = addToManifest(
104
107
  baseDir,
105
108
  {
106
109
  name,
@@ -124,6 +127,12 @@ export const artifactTool: ToolDefinition = {
124
127
  const galleryUrl = port ? `http://localhost:${port}` : undefined
125
128
  const versionLine = archivedVersion ? ` Prev archived as: ${archivedVersion}` : ''
126
129
  const galleryLine = galleryUrl ? `Gallery: ${galleryUrl}` : ''
130
+ // The index was unreadable and got moved aside, so this publish started from
131
+ // nothing: say it here, or the user reads "saved" and never learns that the
132
+ // rest of the index is now a file next to it.
133
+ const warnLine = quarantined
134
+ ? ` ⚠️ Index was unreadable; previous index kept at ${quarantined}`
135
+ : ''
127
136
 
128
137
  return {
129
138
  success: true,
@@ -132,6 +141,7 @@ export const artifactTool: ToolDefinition = {
132
141
  ` URL: ${url}`,
133
142
  ` Size: ${size.toLocaleString()} bytes`,
134
143
  versionLine,
144
+ warnLine,
135
145
  galleryLine,
136
146
  '',
137
147
  `Open in browser: /artifact open ${name}`,
@@ -1,7 +1,9 @@
1
+ import { resolve } from 'node:path'
2
+ import { existsSync } from 'node:fs'
1
3
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
2
4
  import { sanitizeCommand } from '../../shared/sanitize.ts'
3
5
  import { DANGEROUS_GIT_PATTERNS } from './git.ts'
4
- import { isUncOrDevicePath } from '../../security/path.ts'
6
+ import { isUncOrDevicePath, isWithin } from '../../security/path.ts'
5
7
  import { findWorktreeMarker } from '../../core/paths.ts'
6
8
  import type { Service } from '../../vajra'
7
9
  import { toolKey } from '../seam'
@@ -308,6 +310,36 @@ function parseErrorLocations(stderr: string): ErrorLocation[] {
308
310
  return unique.slice(0, 10)
309
311
  }
310
312
 
313
+ /**
314
+ * 找出命令里第一个 `cd` 到工作区之外的**目标原样字符串**(供错误文案用);
315
+ * 无逃逸返回 null。判定边界是 `worktreeRoot`(项目根),不是 `cwd` ——
316
+ * 既有行为即如此:`cd <项目内其它目录>` 放行(见 test/tools/exec.test.ts
317
+ * 「allows cd inside the project from a .mipham worktree」)。
318
+ *
319
+ * 此前三个缺陷,其中两个是活的绕过:
320
+ * - 相对路径用字符串拼接而非 `resolve`:`cd ../../../..` 拼出来的串仍以
321
+ * cwd 开头,于是被当成「在区内」放行 —— **活绕过**;
322
+ * - `command.match(...)` 非全局,只看第一个 `cd`,`cd sub && cd /etc` 的
323
+ * 后半段完全不检查 —— **活绕过**;
324
+ * - 归属判定用 `resolved.startsWith(cwd)` 字符串前缀比较,`/proj/w1-evil`
325
+ * 会被判成「在 /proj/w1 里」;它只被 root 那个析取项兜住才没显形,故一并
326
+ * 改成按路径分段比较的 `isWithin`。
327
+ */
328
+ export function resolveWorktreeEscape(
329
+ cwd: string,
330
+ worktreeRoot: string,
331
+ command: string,
332
+ ): string | null {
333
+ const cdRe = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/g
334
+ for (const m of command.matchAll(cdRe)) {
335
+ const target = m[1] ?? m[2] ?? m[3]
336
+ if (!target) continue
337
+ const resolved = resolve(cwd, target)
338
+ if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) return target
339
+ }
340
+ return null
341
+ }
342
+
311
343
  export function createBashTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
312
344
  return {
313
345
  name: 'Bash',
@@ -332,30 +364,36 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
332
364
  },
333
365
  async execute(params, ctx) {
334
366
  const command = params.command as string
335
- const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
367
+ const requestedTimeout = params.timeout as number | undefined
368
+ // A negative timeout is not "no timeout". `Math.min(-1 || 120_000, 600_000)` is `-1`,
369
+ // and `setTimeout(fn, -1)` is clamped to **1 ms** — so the command is group-killed on
370
+ // the spot and reported as a bare `Exit code 137` (measured with real bun). Node does
371
+ // warn, but only on stderr, where the model never sees it. Refuse rather than silently
372
+ // reinterpret; `0`, `NaN` and `undefined` already fall back to the default via `||`.
373
+ if (typeof requestedTimeout === 'number' && requestedTimeout < 0) {
374
+ return {
375
+ success: false,
376
+ content: '',
377
+ error:
378
+ `timeout must not be negative (got ${requestedTimeout}ms). ` +
379
+ `Omit it for the 120000ms default.`,
380
+ }
381
+ }
382
+ const timeout = Math.min(requestedTimeout || 120_000, 600_000)
336
383
 
337
384
  // P0-4: Worktree isolation — block cd escape attempts
338
385
  // 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,
339
386
  // 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
340
387
  const worktreeMarker = findWorktreeMarker(ctx.cwd)
341
388
  if (worktreeMarker) {
342
- // Detect cd to absolute paths outside the worktree
343
- const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
344
- const cdMatch = command.match(cdEscapePattern)
345
- if (cdMatch) {
346
- const target = cdMatch[1] || cdMatch[2] || cdMatch[3] || ''
347
- // Resolve relative to cwd
348
- const resolved = target.startsWith('/')
349
- ? target
350
- : `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
351
- if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeMarker.root + '/')) {
352
- return {
353
- success: false,
354
- content: '',
355
- error:
356
- `Worktree isolation: cannot cd outside worktree directory. ` +
357
- `Attempted: ${target}. Use tools within the worktree only.`,
358
- }
389
+ const escapeTarget = resolveWorktreeEscape(ctx.cwd, worktreeMarker.root, command)
390
+ if (escapeTarget !== null) {
391
+ return {
392
+ success: false,
393
+ content: '',
394
+ error:
395
+ `Worktree isolation: cannot cd outside worktree directory. ` +
396
+ `Attempted: ${escapeTarget}. Use tools within the worktree only.`,
359
397
  }
360
398
  }
361
399
  }
@@ -379,15 +417,42 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
379
417
  stdout: 'pipe',
380
418
  stderr: 'pipe',
381
419
  env: spawnEnv,
420
+ // Own process group. Required for the group kill below to reach
421
+ // grandchildren — and for it to target *our* group at all: without
422
+ // this the child inherits the parent's pgid, so `kill(-pid)` aims at
423
+ // the wrong group and fails (or, worse, hits the parent's).
424
+ detached: true,
382
425
  })
383
426
 
384
- const timer = setTimeout(() => proc.kill(), timeout)
385
- const rawOutput = await new Response(proc.stdout).text()
427
+ // Start reading at once — a child that fills the pipe buffer blocks on
428
+ // write and would then never exit — but do **not** await here: if a
429
+ // descendant inherits the pipe and outlives the shell, EOF never comes,
430
+ // and awaiting this before `exited` is what hung the call for good.
431
+ const stdoutRead = new Response(proc.stdout).text()
432
+ const stderrRead = new Response(proc.stderr).text()
433
+
434
+ // Remember whether we were the ones who killed it: the exit code is 137 and
435
+ // stderr is empty either way, so the model cannot tell a timeout from the
436
+ // command's own failure (measured with real bun — both are `Exit code 137: `).
437
+ let timedOut = false
438
+ const timer = setTimeout(() => {
439
+ timedOut = true
440
+ killProcessGroup(proc.pid)
441
+ }, timeout)
386
442
  const exitCode = await proc.exited
387
443
  clearTimeout(timer)
388
444
 
445
+ // The shell is gone, so only a pipe-holding descendant can still be
446
+ // holding these up. Released by the group kill, at most once.
447
+ let released = false
448
+ const release = () => {
449
+ if (released) return
450
+ released = true
451
+ killProcessGroup(proc.pid)
452
+ }
453
+ const rawOutput = await settlePipe(stdoutRead, release)
389
454
  // Read stderr for violation detection and error reporting
390
- const rawStderr = await new Response(proc.stderr).text()
455
+ const rawStderr = await settlePipe(stderrRead, release)
391
456
 
392
457
  // ── Credential masking: scrub output ──
393
458
  let output = rawOutput
@@ -428,7 +493,9 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
428
493
  return {
429
494
  success: false,
430
495
  content: errorContent,
431
- error: `Exit code ${exitCode}: ${stderr.slice(0, 1_000)}`,
496
+ error: timedOut
497
+ ? `Command timed out after ${timeout}ms (killed): ${stderr.slice(0, 1_000)}`
498
+ : `Exit code ${exitCode}: ${stderr.slice(0, 1_000)}`,
432
499
  }
433
500
  }
434
501
 
@@ -438,16 +505,71 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
438
505
  }
439
506
  return { success: true, content: successContent }
440
507
  } catch (err) {
508
+ // A missing `cwd` and a missing `bash` both surface as the *same*
509
+ // `ENOENT: no such file or directory, posix_spawn 'bash'` (measured), which reads
510
+ // as "bash is not installed" and points the model at the wrong root cause. The cwd
511
+ // is the one we can actually check — so check it, and only claim it when it is
512
+ // genuinely absent, or a truly missing bash would get relabelled as a bad cwd.
513
+ const code = (err as NodeJS.ErrnoException | undefined)?.code
441
514
  return {
442
515
  success: false,
443
516
  content: '',
444
- error: `Command failed: ${String(err)}`,
517
+ error:
518
+ code === 'ENOENT' && !existsSync(ctx.cwd)
519
+ ? `Working directory does not exist: ${ctx.cwd}`
520
+ : `Command failed: ${String(err)}`,
445
521
  }
446
522
  }
447
523
  },
448
524
  }
449
525
  }
450
526
 
527
+ /** Grace given to a descendant still holding the output pipe after the shell itself has exited. */
528
+ const PIPE_GRACE_MS = 1_000
529
+
530
+ /**
531
+ * Kill a whole process group. `proc.kill()` reaches only the direct child, so a
532
+ * `bash -c` that spawned its own children leaves them orphaned and unnotified.
533
+ * The negative pid addresses the group led by that pid, which exists only when
534
+ * the child was spawned `detached` — verified on this host: without it the
535
+ * child's pgid is the *parent's* group, and this call fails with ESRCH rather
536
+ * than reaching the grandchildren.
537
+ */
538
+ export function killProcessGroup(pid: number | undefined): void {
539
+ if (pid === undefined || pid <= 0) return
540
+ try {
541
+ process.kill(-pid, 'SIGKILL')
542
+ } catch (err: unknown) {
543
+ // ESRCH: the group is already gone, which is the normal case when the
544
+ // command finished on its own. Anything else means a group kill isn't
545
+ // available here, so fall back to the direct child.
546
+ if ((err as NodeJS.ErrnoException).code === 'ESRCH') return
547
+ try {
548
+ process.kill(pid, 'SIGKILL')
549
+ } catch {
550
+ // Already gone.
551
+ }
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Await an already-started pipe read, but not forever. A descendant that
557
+ * inherited the pipe keeps EOF from arriving, and that read is what used to
558
+ * hang the call after the command itself had finished. Once the grace expires,
559
+ * take the group down — which closes the pipe — and finish the read.
560
+ */
561
+ async function settlePipe<T>(read: Promise<T>, release: () => void): Promise<T> {
562
+ let timer: ReturnType<typeof setTimeout> | undefined
563
+ const stalled = new Promise<null>((resolve) => {
564
+ timer = setTimeout(() => resolve(null), PIPE_GRACE_MS)
565
+ })
566
+ const winner = await Promise.race([read, stalled])
567
+ clearTimeout(timer)
568
+ if (winner !== null) return winner
569
+ release()
570
+ return read
571
+ }
572
+
451
573
  export const bashToolService: Service = {
452
574
  inject: ['credentials'],
453
575
  apply(ctx) {
@@ -1,5 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
- import { worktreeRoot } from '../../core/paths.ts'
2
+ import { listsWorktree, worktreeRoot } from '../../core/paths.ts'
3
3
 
4
4
  export const enterWorktreeTool: ToolDefinition = {
5
5
  name: 'EnterWorktree',
@@ -95,7 +95,7 @@ export const enterWorktreeTool: ToolDefinition = {
95
95
  stderr: 'pipe',
96
96
  })
97
97
  const existingWorktrees = await new Response(checkProc.stdout).text()
98
- if (existingWorktrees.includes(worktreePath)) {
98
+ if (listsWorktree(existingWorktrees, worktreePath)) {
99
99
  return {
100
100
  success: true,
101
101
  content:
@@ -126,8 +126,14 @@ export const enterWorktreeTool: ToolDefinition = {
126
126
 
127
127
  // Create worktree with new branch
128
128
  const branchName = `worktree/${name}`
129
+ // `--` ends git's option parsing. `baseRef` comes from the model and is
130
+ // otherwise unvalidated, so without it a ref spelled `--force` is read as
131
+ // an *option*: measured on this machine, `git worktree add -b b <path>
132
+ // --force` succeeds, while a bogus ref in that same slot is `fatal:
133
+ // invalid reference`. After `--` git reads it as a ref and rejects
134
+ // nonsense, which is what a bad base ref should do.
129
135
  const proc = Bun.spawn(
130
- ['git', 'worktree', 'add', '-b', branchName, worktreePath, resolvedBaseRef],
136
+ ['git', 'worktree', 'add', '-b', branchName, worktreePath, '--', resolvedBaseRef],
131
137
  { cwd, stdout: 'pipe', stderr: 'pipe' },
132
138
  )
133
139
  const _stdout = await new Response(proc.stdout).text()