@miphamai/cli 0.81.8 → 0.82.0
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/bin/mipham.ts +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +7 -1
- package/src/config/loader.ts +202 -63
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/crsi-modify.ts +34 -1
- package/src/core/crsi-producer.ts +77 -7
- package/src/core/crsi-sandbox.ts +65 -0
- package/src/core/engine.ts +7 -2
- package/src/core/eval-harness.ts +77 -4
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/improvement-track.ts +41 -0
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +17 -2
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +5 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +44 -17
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +13 -2
- package/src/providers/anthropic.ts +48 -11
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +19 -1
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/exec/bash.ts +106 -6
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +76 -1
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +33 -3
- package/src/tools/index.ts +12 -4
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +205 -30
- 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 = [
|
|
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
|
|
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.
|
|
12
|
+
export const PACKAGE_VERSION = '0.82.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/sanitize.ts
CHANGED
|
@@ -10,14 +10,39 @@
|
|
|
10
10
|
* to ASCII equivalents for permission checks.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
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
|
package/src/shared/types.ts
CHANGED
|
@@ -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
|
package/src/shared/update.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 →
|
|
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
|
/**
|
package/src/tools/agent/agent.ts
CHANGED
|
@@ -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
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
407
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
70
|
+
if (!listsWorktree(listOutput, resolvedPath)) {
|
|
68
71
|
return {
|
|
69
72
|
success: false,
|
|
70
73
|
content: '',
|
package/src/tools/exec/git.ts
CHANGED
|
@@ -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', ...
|
|
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()
|
package/src/tools/file/glob.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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
|
}
|
package/src/tools/file/grep.ts
CHANGED
|
@@ -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:
|
|
234
|
+
error: `Search failed to start: ${describeError(err)}`,
|
|
205
235
|
}
|
|
206
236
|
}
|
|
207
237
|
},
|
package/src/tools/index.ts
CHANGED
|
@@ -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 {
|
|
34
|
+
import { loadUserCredentialMaskingConfig } from '../config/loader'
|
|
35
35
|
|
|
36
36
|
function defaultVajraContext(): Context {
|
|
37
37
|
const ctx = new Context()
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
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
|
|