@miphamai/cli 0.81.8 → 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 (49) hide show
  1. package/bin/mipham.ts +35 -1
  2. package/package.json +1 -1
  3. package/src/agent/message-bus.ts +10 -3
  4. package/src/agent/sub-agent.ts +60 -12
  5. package/src/agent/types.ts +14 -1
  6. package/src/config/credential-crypto.ts +28 -5
  7. package/src/config/defaults.ts +18 -10
  8. package/src/config/keys-manager.ts +7 -1
  9. package/src/config/loader.ts +202 -63
  10. package/src/core/credential-masker/output-scrub.ts +16 -2
  11. package/src/core/engine.ts +7 -2
  12. package/src/core/hooks-executor.ts +30 -2
  13. package/src/core/hooks.ts +51 -4
  14. package/src/core/paths.ts +44 -1
  15. package/src/core/permission-config.ts +146 -14
  16. package/src/core/permission-rules.ts +17 -2
  17. package/src/core/permission.ts +81 -13
  18. package/src/core/rules-loader.ts +35 -5
  19. package/src/core/session-log.ts +5 -1
  20. package/src/core/workspace-trust.ts +42 -4
  21. package/src/daemon/auth.ts +15 -14
  22. package/src/daemon/engine-capabilities.ts +12 -2
  23. package/src/daemon/remote-engine.ts +9 -4
  24. package/src/daemon/server.ts +29 -1
  25. package/src/i18n-core/locales/en-US.json +12 -8
  26. package/src/i18n-core/locales/zh-CN.json +12 -8
  27. package/src/index.tsx +44 -17
  28. package/src/mcp/client.ts +24 -0
  29. package/src/mcp/http-transport.ts +35 -3
  30. package/src/plugin/plugin-manager.ts +13 -2
  31. package/src/providers/anthropic.ts +48 -11
  32. package/src/security/gate.ts +18 -0
  33. package/src/security/path.ts +19 -1
  34. package/src/shared/arg-validation.ts +37 -2
  35. package/src/shared/package-info.ts +1 -1
  36. package/src/shared/sanitize.ts +27 -2
  37. package/src/shared/types.ts +8 -0
  38. package/src/shared/update.ts +22 -5
  39. package/src/tools/agent/agent.ts +3 -0
  40. package/src/tools/exec/bash.ts +106 -6
  41. package/src/tools/exec/enter-worktree.ts +9 -3
  42. package/src/tools/exec/exit-worktree.ts +6 -3
  43. package/src/tools/exec/git.ts +76 -1
  44. package/src/tools/file/glob.ts +19 -3
  45. package/src/tools/file/grep.ts +33 -3
  46. package/src/tools/index.ts +12 -4
  47. package/src/ui/app.tsx +47 -11
  48. package/src/ui/commands.ts +160 -30
  49. package/src/workflow/primitives/agent.ts +4 -0
@@ -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
  }
@@ -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.8' 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
@@ -276,6 +276,14 @@ export interface HookDefinition {
276
276
 
277
277
  export interface HookContext {
278
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
279
287
  toolName?: string
280
288
  toolInput?: Record<string, unknown>
281
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,4 +1,5 @@
1
1
  import { resolve } from 'node:path'
2
+ import { existsSync } from 'node:fs'
2
3
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
4
  import { sanitizeCommand } from '../../shared/sanitize.ts'
4
5
  import { DANGEROUS_GIT_PATTERNS } from './git.ts'
@@ -363,7 +364,22 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
363
364
  },
364
365
  async execute(params, ctx) {
365
366
  const command = params.command as string
366
- 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)
367
383
 
368
384
  // P0-4: Worktree isolation — block cd escape attempts
369
385
  // 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,
@@ -401,15 +417,42 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
401
417
  stdout: 'pipe',
402
418
  stderr: 'pipe',
403
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,
404
425
  })
405
426
 
406
- const timer = setTimeout(() => proc.kill(), timeout)
407
- 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)
408
442
  const exitCode = await proc.exited
409
443
  clearTimeout(timer)
410
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)
411
454
  // Read stderr for violation detection and error reporting
412
- const rawStderr = await new Response(proc.stderr).text()
455
+ const rawStderr = await settlePipe(stderrRead, release)
413
456
 
414
457
  // ── Credential masking: scrub output ──
415
458
  let output = rawOutput
@@ -450,7 +493,9 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
450
493
  return {
451
494
  success: false,
452
495
  content: errorContent,
453
- 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)}`,
454
499
  }
455
500
  }
456
501
 
@@ -460,16 +505,71 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
460
505
  }
461
506
  return { success: true, content: successContent }
462
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
463
514
  return {
464
515
  success: false,
465
516
  content: '',
466
- 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)}`,
467
521
  }
468
522
  }
469
523
  },
470
524
  }
471
525
  }
472
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
+
473
573
  export const bashToolService: Service = {
474
574
  inject: ['credentials'],
475
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()
@@ -1,5 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
- import { worktreeRoots } from '../../core/paths.ts'
2
+ import { listsWorktree, worktreeRoots } from '../../core/paths.ts'
3
3
 
4
4
  export const exitWorktreeTool: ToolDefinition = {
5
5
  name: 'ExitWorktree',
@@ -39,7 +39,10 @@ export const exitWorktreeTool: ToolDefinition = {
39
39
  // Validate the path is under a worktree root(新目录优先,兼容历史 .claude/)
40
40
  const cwd = ctx.cwd
41
41
  const { resolve } = await import('node:path')
42
- const resolvedPath = resolve(worktreePath)
42
+ // 基数必须是 `cwd`(= ctx.cwd):下面每一个 `Bun.spawn` 都带 `cwd: ctx.cwd`,
43
+ // 单参数 `resolve()` 却拿 `process.cwd()` 当归宿 —— 相对路径于是「校验的是 A、
44
+ // 执行的是 B」。两者在 daemon(ctx.cwd 是会话工作区、不是进程启动目录)下分叉。
45
+ const resolvedPath = resolve(cwd, worktreePath)
43
46
  const roots = worktreeRoots(cwd).map((root) => resolve(root))
44
47
  const inWorktree = roots.some(
45
48
  (root) => resolvedPath === root || resolvedPath.startsWith(root + '/'),
@@ -64,7 +67,7 @@ export const exitWorktreeTool: ToolDefinition = {
64
67
  })
65
68
  const listOutput = await new Response(listProc.stdout).text()
66
69
 
67
- if (!listOutput.includes(worktreePath)) {
70
+ if (!listsWorktree(listOutput, resolvedPath)) {
68
71
  return {
69
72
  success: false,
70
73
  content: '',
@@ -3,6 +3,9 @@ import type { ToolDefinition } from '../../shared/index.ts'
3
3
  import { findWorktreeMarker } from '../../core/paths.ts'
4
4
  import { isWithin } from '../../security/path.ts'
5
5
 
6
+ /** Generous enough for a clone or fetch, short enough to bound a hung git. */
7
+ const GIT_TIMEOUT_MS = 120_000
8
+
6
9
  // P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
7
10
  // fragile substring matching. Each pattern describes what it blocks.
8
11
  export const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
@@ -90,6 +93,60 @@ function isOutsideWorktree(command: string, cwd: string): string | null {
90
93
  return null
91
94
  }
92
95
 
96
+ /**
97
+ * Git options whose value is a program git will execute. The regex list above
98
+ * covers command execution reached through config keys; these are the ones it
99
+ * does not name at all, and each was confirmed to run an arbitrary local
100
+ * program: `ls-remote --upload-pack=/tmp/x.sh <path>`, `push --receive-pack=…`,
101
+ * and `--exec-path=<dir>` followed by a planted `<dir>/git-<subcommand>`.
102
+ */
103
+ const PROGRAM_EXECUTING_OPTIONS = ['--upload-pack', '--receive-pack', '--exec-path']
104
+
105
+ /** Config keys whose value git runs as a program. */
106
+ const PROGRAM_EXECUTING_CONFIG_KEY =
107
+ /^(?:core\.(?:sshCommand|pager|askpass|editor)|alias\.|credential\.helper)/i
108
+
109
+ /**
110
+ * Find an argv entry that makes git execute a program the caller named.
111
+ *
112
+ * This runs on the parsed argv rather than the raw command string. The regex
113
+ * list above is organised by *spelling*, so it has to anticipate every way the
114
+ * text can be written — `" -c " + "core.pager=…" ` written with quotes between
115
+ * the two halves never matches it, yet git receives the same two tokens. argv
116
+ * is what git actually gets, so it has no such gap.
117
+ */
118
+ export function findProgramExecutingArg(argv: string[]): string | null {
119
+ for (let i = 0; i < argv.length; i++) {
120
+ const arg = argv[i]!
121
+
122
+ for (const opt of PROGRAM_EXECUTING_OPTIONS) {
123
+ // `--upload-pack=<exec>` and `--upload-pack <exec>` are both accepted.
124
+ if (arg === opt) return `${arg} ${argv[i + 1] ?? ''}`
125
+ if (arg.startsWith(`${opt}=`)) return arg
126
+ }
127
+
128
+ // `-c<key>=<value>` / `--config=<key>=<value>`. `--config=…` is checked
129
+ // first so `/^-c[^-]/` cannot swallow it.
130
+ const attached = arg.startsWith('--config=')
131
+ ? arg.slice('--config='.length)
132
+ : /^-c[^-]/.test(arg)
133
+ ? arg.slice(2)
134
+ : null
135
+ if (attached !== null && PROGRAM_EXECUTING_CONFIG_KEY.test(attached)) return arg
136
+
137
+ // `-c <key>=<value>` / `--config <key>=<value>`. A bare `-c` also means
138
+ // "reuse this commit" for `git commit`, but that value never contains `=`,
139
+ // so the config read cannot swallow it.
140
+ if ((arg === '-c' || arg === '--config') && i + 1 < argv.length) {
141
+ const next = argv[i + 1]!
142
+ if (next.includes('=') && PROGRAM_EXECUTING_CONFIG_KEY.test(next)) {
143
+ return `${arg} ${next}`
144
+ }
145
+ }
146
+ }
147
+ return null
148
+ }
149
+
93
150
  /**
94
151
  * Split a git command string into argv tokens, honoring shell-style quoting
95
152
  * (single quotes, double quotes, and backslash escapes). Unlike a naive
@@ -183,14 +240,32 @@ export const gitTool: ToolDefinition = {
183
240
  }
184
241
  }
185
242
 
243
+ // Git runs without an approval prompt (`permission: 'auto'`), so an option
244
+ // that names a program to execute is a code-execution path Bash would have
245
+ // had to ask for. Checked on argv, which is what git is handed below.
246
+ const argv = splitCommand(command)
247
+ const execArg = findProgramExecutingArg(argv)
248
+ if (execArg) {
249
+ return {
250
+ success: false,
251
+ content: '',
252
+ error: `Dangerous git option blocked: "${execArg}" makes git run an arbitrary program. Run manually if intended.`,
253
+ }
254
+ }
255
+
186
256
  try {
187
- const proc = Bun.spawn(['git', ...splitCommand(command)], {
257
+ const proc = Bun.spawn(['git', ...argv], {
188
258
  cwd: ctx.cwd,
189
259
  stdout: 'pipe',
190
260
  stderr: 'pipe',
191
261
  })
262
+
263
+ // Bounded like Bash: a git command that blocks on a pager, a credential
264
+ // prompt, or an unreachable remote would otherwise hang the turn forever.
265
+ const timer = setTimeout(() => proc.kill(), GIT_TIMEOUT_MS)
192
266
  const output = await new Response(proc.stdout).text()
193
267
  const exitCode = await proc.exited
268
+ clearTimeout(timer)
194
269
 
195
270
  if (exitCode !== 0) {
196
271
  const stderr = await new Response(proc.stderr).text()
@@ -6,6 +6,10 @@ import { toolKey } from '../seam'
6
6
  import { withValidation } from '../validation'
7
7
  import { maskGlobOutput } from '../../core/credential-masker'
8
8
 
9
+ /** 结果上限:超过则显式截断并附标记(不能静默丢内容——模型会误以为看全了,
10
+ * 与 Grep 的 `truncateGrepOutput` 同一约定)。 */
11
+ const GLOB_MAX_RESULTS = 500
12
+
9
13
  export function createGlobTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
10
14
  return {
11
15
  name: 'Glob',
@@ -25,12 +29,24 @@ export function createGlobTool(credentialConfig?: CredentialMaskingConfig): Tool
25
29
  const basePath = resolveSafe(ctx.cwd, (params.path as string) || '.')
26
30
  const glob = new Glob(pattern)
27
31
  const results: string[] = []
32
+ let truncated = false
28
33
  for await (const file of glob.scan({ cwd: basePath, absolute: true })) {
34
+ if (results.length >= GLOB_MAX_RESULTS) {
35
+ // 只有真的还有第 501 个匹配时才叫截断 —— 恰好 500 个匹配是完整结果。
36
+ truncated = true
37
+ break
38
+ }
29
39
  results.push(file)
30
- if (results.length >= 500) break
31
40
  }
32
- const content = results.join('\n') || '(no matches)'
33
- return { success: true, content: maskGlobOutput(content, credentialConfig) }
41
+ // 先掩码正文、再拼注解:`maskGlobOutput` 是**逐行当路径**去比对的
42
+ // (每行都过 `matchCredentialFile`),注解不是路径,不该喂给它。
43
+ const content = maskGlobOutput(results.join('\n') || '(no matches)', credentialConfig)
44
+ return {
45
+ success: true,
46
+ content: truncated
47
+ ? `${content}\n\n... (truncated at ${GLOB_MAX_RESULTS} matches — narrow the pattern or "path")`
48
+ : content,
49
+ }
34
50
  },
35
51
  }
36
52
  }
@@ -54,6 +54,27 @@ export function isTopLevelScope(searchPath: string, home = homedir()): boolean {
54
54
  return searchPath === home || searchPath === parse(searchPath).root
55
55
  }
56
56
 
57
+ /** 把未知异常变成一句可读的原因(`code` 才是可操作的部分:ENOENT / EAGAIN)。 */
58
+ function describeError(err: unknown): string {
59
+ if (err instanceof Error) {
60
+ const code = (err as { code?: unknown }).code
61
+ return typeof code === 'string' ? `${code}: ${err.message}` : err.message
62
+ }
63
+ return String(err)
64
+ }
65
+
66
+ /**
67
+ * 只有「rg 不在 PATH」才该回退到 `find`。别的异常(EAGAIN / EPERM / spawn
68
+ * 失败)说明 rg 装了却起不来 —— 那不是回退能解决的问题,再起一个 find 只会
69
+ * 换来第二个失败,而错误信息里的「去装 ripgrep」会把排查方向整条带偏。
70
+ */
71
+ function isMissingBinary(err: unknown): boolean {
72
+ if (!(err instanceof Error)) return false
73
+ if ((err as { code?: unknown }).code === 'ENOENT') return true
74
+ // 有些抛出方只给句子不给 code。
75
+ return /ENOENT|not found|no such file or directory/i.test(err.message)
76
+ }
77
+
57
78
  export function createGrepTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
58
79
  return {
59
80
  name: 'Grep',
@@ -139,7 +160,14 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
139
160
  'rg error (exit 2) — likely permission denied on a large/protected tree. ' +
140
161
  'Narrow scope with "path" (project directory) and "include".',
141
162
  }
142
- } catch {
163
+ } catch (err) {
164
+ if (!isMissingBinary(err)) {
165
+ return {
166
+ success: false,
167
+ content: '',
168
+ error: `ripgrep failed: ${describeError(err)}`,
169
+ }
170
+ }
143
171
  // rg not installed → fall through to grep (find + grep fallback)
144
172
  }
145
173
 
@@ -197,11 +225,13 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
197
225
  (stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : '') +
198
226
  '. Install ripgrep: brew install ripgrep',
199
227
  }
200
- } catch {
228
+ } catch (err) {
229
+ // 这里回退自己起不来 —— 报回退的真实原因,别再断言「rg 没装」
230
+ // (走到这一步的路径本来就有「rg 没装」和「装了但起不来」两种)。
201
231
  return {
202
232
  success: false,
203
233
  content: '',
204
- error: 'grep failed. Install ripgrep: brew install ripgrep',
234
+ error: `Search failed to start: ${describeError(err)}`,
205
235
  }
206
236
  }
207
237
  },
@@ -31,13 +31,21 @@ import { listAgentsTool } from './agent/list-agents'
31
31
  import { computerUseTool } from './computer/computer-use'
32
32
  import { scheduleWakeupTool } from './scheduling/schedule-wakeup.js'
33
33
  import { cronCreateTool, cronDeleteTool, cronListTool } from './scheduling/cron.js'
34
- import { DISABLED_CREDENTIAL_MASKING_CONFIG } from '../config/defaults'
34
+ import { loadUserCredentialMaskingConfig } from '../config/loader'
35
35
 
36
36
  function defaultVajraContext(): Context {
37
37
  const ctx = new Context()
38
- // 掩码中立默认:无参调用(daemon/workflow)保持 Read/Bash 挂载,但不启用掩码,
39
- // 对齐 pre-seam 行为(那些路径从不调用 setter)。显式开启掩码走 index.tsx 的加载配置。
40
- ctx.provide('credentials', DISABLED_CREDENTIAL_MASKING_CONFIG)
38
+ // Fail-closed 默认:无参调用(daemon / workflow)拿到的是**用户级**掩码策略,
39
+ // 且配置读不出来时留下的是默认值(掩码开),不是一块关掉的掩码。
40
+ //
41
+ // 原先给的是 DISABLED 配置,理由是「对齐 pre-seam 行为」—— 但掩码是安全控制,
42
+ // 「没配置就关掉」是把默认值的方向定反了:daemon 里 Read/Bash/Grep/Glob 的
43
+ // 掩码整套失效,子进程继承完整 process.env、输出不擦洗。
44
+ //
45
+ // 只取用户级、不取项目级:注册表是 daemon 进程级的一个实例而会话 cwd 各不相同
46
+ // (见 `loadUserCredentialMaskingConfig` 的注释),把某个项目的段套上去等于让它溢到
47
+ // 别的会话。交互式 CLI 走 index.tsx 自己的 ctx(含项目级),不经过这里。
48
+ ctx.provide('credentials', loadUserCredentialMaskingConfig())
41
49
  return ctx
42
50
  }
43
51