@miphamai/cli 0.81.6 → 0.81.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/bin/daemon.ts +7 -32
- package/bin/mipham.ts +43 -29
- package/package.json +5 -2
- package/skills/standard/mipham-code-setup.SKILL.md +3 -3
- package/src/agent/sub-agent.ts +12 -1
- package/src/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +10 -11
- package/src/config/loader.ts +82 -1
- package/src/config/preferences.ts +5 -2
- package/src/core/context.ts +10 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/engine.ts +19 -4
- package/src/core/metrics.ts +8 -0
- package/src/core/paths.ts +79 -0
- package/src/core/permission-rules.ts +261 -17
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +55 -3
- package/src/core/session-store.ts +11 -1
- package/src/daemon/engine-capabilities.ts +131 -0
- package/src/daemon/index.ts +4 -1
- package/src/daemon/launch.ts +287 -0
- package/src/daemon/remote-engine.ts +2 -0
- package/src/daemon/server.ts +9 -0
- package/src/daemon/session-worker.ts +21 -3
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +82 -2
- package/src/mcp/client.ts +4 -2
- package/src/plugin/plugin-manager.ts +17 -6
- package/src/providers/anthropic.ts +28 -2
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/path.ts +6 -1
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +24 -0
- package/src/skills/bundled-skills.ts +1 -1
- package/src/telemetry/consent.ts +209 -0
- package/src/telemetry/crash.ts +197 -0
- package/src/telemetry/endpoint.ts +82 -0
- package/src/telemetry/index.ts +153 -0
- package/src/telemetry/payload.ts +141 -0
- package/src/telemetry/queue.ts +95 -0
- package/src/telemetry/redact.ts +127 -0
- package/src/telemetry/transport.ts +81 -0
- package/src/tools/agent/workflow.ts +11 -4
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +45 -21
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +25 -10
- package/src/tools/file/grep.ts +37 -13
- package/src/tools/file/read.ts +151 -45
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +9 -5
- package/src/ui/app.tsx +40 -11
- package/src/ui/commands.ts +186 -45
- package/src/workflow/primitives/agent.ts +4 -2
- package/src/artifacts/versioning.ts +0 -127
- package/src/core/task-runner-tasks.json +0 -14
- package/src/core/task-runner.ts +0 -163
- package/src/skills/mipham/runtime.ts +0 -66
- package/src/skills/standard/runtime.ts +0 -62
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Redaction for crash reports.
|
|
6
|
+
*
|
|
7
|
+
* Nothing in this module is reusable from the existing scrubbers: the four
|
|
8
|
+
* families in the repo (`credential-masker/`, `security/gate.ts`,
|
|
9
|
+
* `shared/sanitize.ts`, `skills/sanitizer.ts`) all match *credential shapes* —
|
|
10
|
+
* token prefixes, JWTs, `sk-ant-…`. None of them removes a user name out of a
|
|
11
|
+
* path. The only home→`~` helper is `ui/chat.tsx` `displayCwd()`, which is
|
|
12
|
+
* unexported, has zero call sites, and only ever looks at `process.cwd()` — it
|
|
13
|
+
* cannot touch a path inside an error object or a stack frame.
|
|
14
|
+
*
|
|
15
|
+
* So the guarantee below has to be carried by tests, not by reuse.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Frames kept by default. `Error.stackTraceLimit` is 10; we allow headroom. */
|
|
19
|
+
export const MAX_STACK_FRAMES = 15
|
|
20
|
+
|
|
21
|
+
/** Marker substituted for the working directory — hides the project name. */
|
|
22
|
+
const CWD_MARKER = '<cwd>'
|
|
23
|
+
|
|
24
|
+
/** Marker substituted for the home directory. */
|
|
25
|
+
const HOME_MARKER = '~'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Marker substituted for the first directory segment under home.
|
|
29
|
+
*
|
|
30
|
+
* Home replacement alone still discloses the user's own naming: a frame in
|
|
31
|
+
* `~/acme-secret-merger-2026/src/a.ts` would render as exactly that. The
|
|
32
|
+
* data dictionary promises no project names, so the segment right after `~` is
|
|
33
|
+
* collapsed too. Structure and the frame's own file name survive — which is
|
|
34
|
+
* what makes a frame useful — but the user's label for a directory does not.
|
|
35
|
+
*/
|
|
36
|
+
const DIR_MARKER = '<dir>'
|
|
37
|
+
|
|
38
|
+
function escapeRegExp(s: string): string {
|
|
39
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replace absolute home/cwd paths with their markers, anywhere in the string.
|
|
44
|
+
*
|
|
45
|
+
* Order matters and is not arbitrary: cwd is normally *inside* home, so
|
|
46
|
+
* replacing home first would leave `~/proj/src/a.ts` — which leaks the project
|
|
47
|
+
* directory name. Replacing cwd first yields `<cwd>/src/a.ts`, which does not.
|
|
48
|
+
* This ordering is asserted by a test.
|
|
49
|
+
*
|
|
50
|
+
* Paths that are neither under cwd nor under home are left alone. Those are
|
|
51
|
+
* system/package locations (e.g. `/usr/local/lib/node_modules/…`) that contain
|
|
52
|
+
* no user data, and keeping them is what makes a frame useful for debugging.
|
|
53
|
+
*/
|
|
54
|
+
export function redactText(input: string, cwd?: string): string {
|
|
55
|
+
let out = input
|
|
56
|
+
|
|
57
|
+
if (cwd && cwd.length > 1) {
|
|
58
|
+
out = out.replace(new RegExp(escapeRegExp(cwd), 'g'), CWD_MARKER)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let home = ''
|
|
62
|
+
try {
|
|
63
|
+
home = homedir()
|
|
64
|
+
} catch {
|
|
65
|
+
/* homedir() can throw when neither HOME nor the passwd entry resolves */
|
|
66
|
+
}
|
|
67
|
+
if (home && home.length > 1) {
|
|
68
|
+
out = out.replace(new RegExp(escapeRegExp(home), 'g'), HOME_MARKER)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Collapse the first segment after `~` (both separators — Windows stacks use
|
|
72
|
+
// backslashes) so a user-chosen directory name is not disclosed. The matched
|
|
73
|
+
// separator is echoed back rather than normalised, so a Windows path does not
|
|
74
|
+
// come out with mixed separators.
|
|
75
|
+
out = out.replace(
|
|
76
|
+
/~([\\/])[^\\/\s:)]+/g,
|
|
77
|
+
(_match, sep: string) => `${HOME_MARKER}${sep}${DIR_MARKER}`,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RedactedStack {
|
|
84
|
+
/** Redacted frame lines, truncated to `maxFrames`. */
|
|
85
|
+
frames: string[]
|
|
86
|
+
/** Frame count *before* truncation — truncation loses data, this bounds the loss. */
|
|
87
|
+
frameCount: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract and redact the frame lines of a V8 stack.
|
|
92
|
+
*
|
|
93
|
+
* The first line of a stack is `"TypeError: <message>"` — it is dropped
|
|
94
|
+
* outright, never redacted, because the message routinely embeds paths and user
|
|
95
|
+
* data. `crash.ts` sends only `hashMessage(error.message)` instead.
|
|
96
|
+
*/
|
|
97
|
+
export function redactStack(
|
|
98
|
+
stack: string,
|
|
99
|
+
opts: { cwd?: string; maxFrames?: number } = {},
|
|
100
|
+
): RedactedStack {
|
|
101
|
+
const { cwd = process.cwd(), maxFrames = MAX_STACK_FRAMES } = opts
|
|
102
|
+
|
|
103
|
+
const frameLines = stack.split('\n').filter((line) => /^\s*at\s/.test(line))
|
|
104
|
+
const redacted = frameLines.map((line) => redactText(line.trim(), cwd))
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
frames: redacted.slice(0, maxFrames),
|
|
108
|
+
frameCount: frameLines.length,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* sha256 of the error message, first 16 hex chars.
|
|
114
|
+
*
|
|
115
|
+
* Only the digest is reportable. The plaintext message is not — same reason the
|
|
116
|
+
* stack's first line is dropped.
|
|
117
|
+
*/
|
|
118
|
+
export function hashMessage(message: string): string {
|
|
119
|
+
return createHash('sha256').update(message).digest('hex').slice(0, 16)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Runtime tag for the session payload, e.g. `bun@1.2` / `node@22`. */
|
|
123
|
+
export function runtimeTag(): string {
|
|
124
|
+
const versions = process.versions as Record<string, string | undefined>
|
|
125
|
+
if (versions.bun) return `bun@${versions.bun}`
|
|
126
|
+
return `node@${(versions.node ?? '0').split('.')[0]}`
|
|
127
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { fetchWithRetry } from '../providers/fetch-utils'
|
|
2
|
+
import { ackQueue, readQueue, type QueuedEvent } from './queue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Upload queued events.
|
|
6
|
+
*
|
|
7
|
+
* Runs at *startup*, not at exit: `process.on('exit')` cannot await async work,
|
|
8
|
+
* so collecting and sending are decoupled — the session queue is written
|
|
9
|
+
* synchronously at exit and drained here on the next launch. Same shape as the
|
|
10
|
+
* existing startup update check (`shared/update.ts`), which is likewise
|
|
11
|
+
* fire-and-forget.
|
|
12
|
+
*
|
|
13
|
+
* Every failure is silent and leaves the event queued for the next attempt. A
|
|
14
|
+
* telemetry endpoint being unreachable must never be visible to the user.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Per-request budget. Short: this runs alongside startup, not during it. */
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 10_000
|
|
19
|
+
|
|
20
|
+
export interface FlushResult {
|
|
21
|
+
sent: number
|
|
22
|
+
failed: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Drain the queue to `endpoint`.
|
|
27
|
+
*
|
|
28
|
+
* Returns without sending anything when the endpoint is empty — which is the
|
|
29
|
+
* shipped default, so an unconfigured install performs **zero** network calls.
|
|
30
|
+
*/
|
|
31
|
+
export async function flushQueue(
|
|
32
|
+
endpoint: string,
|
|
33
|
+
opts: { fetchImpl?: typeof fetch; events?: QueuedEvent[] } = {},
|
|
34
|
+
): Promise<FlushResult> {
|
|
35
|
+
if (!endpoint) return { sent: 0, failed: 0 }
|
|
36
|
+
|
|
37
|
+
const events = opts.events ?? readQueue()
|
|
38
|
+
if (events.length === 0) return { sent: 0, failed: 0 }
|
|
39
|
+
|
|
40
|
+
const sent: string[] = []
|
|
41
|
+
let failed = 0
|
|
42
|
+
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetchWithRetry(
|
|
46
|
+
endpoint,
|
|
47
|
+
{
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify(event),
|
|
51
|
+
},
|
|
52
|
+
{ timeout: REQUEST_TIMEOUT_MS, maxRetries: 2, baseDelay: 1000 },
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// 4xx means we are sending something the endpoint will never accept —
|
|
56
|
+
// retrying forever would wedge the queue behind a permanently bad event.
|
|
57
|
+
// Drop it on the floor rather than block every later event.
|
|
58
|
+
if (response.ok || (response.status >= 400 && response.status < 500)) {
|
|
59
|
+
sent.push(event.id)
|
|
60
|
+
} else {
|
|
61
|
+
failed++
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
failed++
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ackQueue(sent)
|
|
69
|
+
return { sent: sent.length, failed }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fire-and-forget startup flush. Never rejects, never blocks the caller, and
|
|
74
|
+
* never surfaces an error — the caller does not await it.
|
|
75
|
+
*/
|
|
76
|
+
export function flushQueueInBackground(endpoint: string): void {
|
|
77
|
+
if (!endpoint) return
|
|
78
|
+
void flushQueue(endpoint).catch(() => {
|
|
79
|
+
/* best-effort by design */
|
|
80
|
+
})
|
|
81
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
2
|
import { runWorkflow } from '../../workflow/runtime'
|
|
3
|
+
import { workflowScriptDir } from '../../core/paths.ts'
|
|
3
4
|
import type { QueryEngine } from '../../core/engine'
|
|
4
5
|
|
|
5
6
|
export const workflowTool: ToolDefinition = {
|
|
@@ -108,10 +109,11 @@ export const workflowTool: ToolDefinition = {
|
|
|
108
109
|
)
|
|
109
110
|
|
|
110
111
|
// Persist last-run state for /workflow save
|
|
112
|
+
let persistWarning = ''
|
|
111
113
|
try {
|
|
112
114
|
const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
|
|
113
115
|
const { join } = await import('node:path')
|
|
114
|
-
const workflowsDir =
|
|
116
|
+
const workflowsDir = workflowScriptDir(process.cwd())
|
|
115
117
|
if (!existsSync(workflowsDir)) {
|
|
116
118
|
mkdirSync(workflowsDir, { recursive: true })
|
|
117
119
|
}
|
|
@@ -120,8 +122,13 @@ export const workflowTool: ToolDefinition = {
|
|
|
120
122
|
JSON.stringify({ runId, script, timestamp: new Date().toISOString() }),
|
|
121
123
|
'utf-8',
|
|
122
124
|
)
|
|
123
|
-
} catch {
|
|
124
|
-
// best-effort —
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Persistence stays best-effort — the workflow itself succeeded, so a
|
|
127
|
+
// disk error must not fail it. But it must not be *silent* either:
|
|
128
|
+
// swallowing this made the next `/workflow save` report "No recent
|
|
129
|
+
// workflow run found" while the script sat intact in
|
|
130
|
+
// ~/.mipham/workflows/<runId>/script.js.
|
|
131
|
+
persistWarning = `\n\n⚠️ Could not persist last-run state for /workflow save: ${String(err)}`
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
let content = `Workflow ${runId} completed.\n\n`
|
|
@@ -130,7 +137,7 @@ export const workflowTool: ToolDefinition = {
|
|
|
130
137
|
}
|
|
131
138
|
content += `Result:\n${typeof result === 'string' ? result : JSON.stringify(result, null, 2)}`
|
|
132
139
|
|
|
133
|
-
return { success: true, content }
|
|
140
|
+
return { success: true, content: content + persistWarning }
|
|
134
141
|
} catch (err) {
|
|
135
142
|
return {
|
|
136
143
|
success: false,
|
|
@@ -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 {
|
|
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
|
-
|
|
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}`,
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
1
2
|
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
2
3
|
import { sanitizeCommand } from '../../shared/sanitize.ts'
|
|
3
4
|
import { DANGEROUS_GIT_PATTERNS } from './git.ts'
|
|
4
|
-
import { isUncOrDevicePath } from '../../security/path.ts'
|
|
5
|
+
import { isUncOrDevicePath, isWithin } from '../../security/path.ts'
|
|
6
|
+
import { findWorktreeMarker } from '../../core/paths.ts'
|
|
5
7
|
import type { Service } from '../../vajra'
|
|
6
8
|
import { toolKey } from '../seam'
|
|
7
9
|
import { withValidation } from '../validation'
|
|
@@ -307,6 +309,36 @@ function parseErrorLocations(stderr: string): ErrorLocation[] {
|
|
|
307
309
|
return unique.slice(0, 10)
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
/**
|
|
313
|
+
* 找出命令里第一个 `cd` 到工作区之外的**目标原样字符串**(供错误文案用);
|
|
314
|
+
* 无逃逸返回 null。判定边界是 `worktreeRoot`(项目根),不是 `cwd` ——
|
|
315
|
+
* 既有行为即如此:`cd <项目内其它目录>` 放行(见 test/tools/exec.test.ts
|
|
316
|
+
* 「allows cd inside the project from a .mipham worktree」)。
|
|
317
|
+
*
|
|
318
|
+
* 此前三个缺陷,其中两个是活的绕过:
|
|
319
|
+
* - 相对路径用字符串拼接而非 `resolve`:`cd ../../../..` 拼出来的串仍以
|
|
320
|
+
* cwd 开头,于是被当成「在区内」放行 —— **活绕过**;
|
|
321
|
+
* - `command.match(...)` 非全局,只看第一个 `cd`,`cd sub && cd /etc` 的
|
|
322
|
+
* 后半段完全不检查 —— **活绕过**;
|
|
323
|
+
* - 归属判定用 `resolved.startsWith(cwd)` 字符串前缀比较,`/proj/w1-evil`
|
|
324
|
+
* 会被判成「在 /proj/w1 里」;它只被 root 那个析取项兜住才没显形,故一并
|
|
325
|
+
* 改成按路径分段比较的 `isWithin`。
|
|
326
|
+
*/
|
|
327
|
+
export function resolveWorktreeEscape(
|
|
328
|
+
cwd: string,
|
|
329
|
+
worktreeRoot: string,
|
|
330
|
+
command: string,
|
|
331
|
+
): string | null {
|
|
332
|
+
const cdRe = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/g
|
|
333
|
+
for (const m of command.matchAll(cdRe)) {
|
|
334
|
+
const target = m[1] ?? m[2] ?? m[3]
|
|
335
|
+
if (!target) continue
|
|
336
|
+
const resolved = resolve(cwd, target)
|
|
337
|
+
if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) return target
|
|
338
|
+
}
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
|
|
310
342
|
export function createBashTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
|
|
311
343
|
return {
|
|
312
344
|
name: 'Bash',
|
|
@@ -334,26 +366,18 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
334
366
|
const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
|
|
335
367
|
|
|
336
368
|
// P0-4: Worktree isolation — block cd escape attempts
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeRoot + '/')) {
|
|
350
|
-
return {
|
|
351
|
-
success: false,
|
|
352
|
-
content: '',
|
|
353
|
-
error:
|
|
354
|
-
`Worktree isolation: cannot cd outside worktree directory. ` +
|
|
355
|
-
`Attempted: ${target}. Use tools within the worktree only.`,
|
|
356
|
-
}
|
|
369
|
+
// 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,
|
|
370
|
+
// 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
|
|
371
|
+
const worktreeMarker = findWorktreeMarker(ctx.cwd)
|
|
372
|
+
if (worktreeMarker) {
|
|
373
|
+
const escapeTarget = resolveWorktreeEscape(ctx.cwd, worktreeMarker.root, command)
|
|
374
|
+
if (escapeTarget !== null) {
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
content: '',
|
|
378
|
+
error:
|
|
379
|
+
`Worktree isolation: cannot cd outside worktree directory. ` +
|
|
380
|
+
`Attempted: ${escapeTarget}. Use tools within the worktree only.`,
|
|
357
381
|
}
|
|
358
382
|
}
|
|
359
383
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
import { worktreeRoot } from '../../core/paths.ts'
|
|
2
3
|
|
|
3
4
|
export const enterWorktreeTool: ToolDefinition = {
|
|
4
5
|
name: 'EnterWorktree',
|
|
5
6
|
description:
|
|
6
7
|
'Create an isolated git worktree for parallel development. ' +
|
|
7
|
-
'Creates a new worktree at .
|
|
8
|
+
'Creates a new worktree at .mipham/worktrees/<name> on its own branch. ' +
|
|
8
9
|
'Use this when you need to work on a separate task without affecting the main workspace. ' +
|
|
9
10
|
'Pair with ExitWorktree to clean up when done.',
|
|
10
11
|
category: 'exec',
|
|
@@ -56,10 +57,10 @@ export const enterWorktreeTool: ToolDefinition = {
|
|
|
56
57
|
|
|
57
58
|
const cwd = ctx.cwd
|
|
58
59
|
const { resolve } = await import('node:path')
|
|
59
|
-
const worktreePath = resolve(
|
|
60
|
-
const allowedPrefix = resolve(
|
|
60
|
+
const worktreePath = resolve(worktreeRoot(cwd), name)
|
|
61
|
+
const allowedPrefix = resolve(worktreeRoot(cwd))
|
|
61
62
|
|
|
62
|
-
// Defense-in-depth: verify resolved path
|
|
63
|
+
// Defense-in-depth: verify resolved path stays within the worktree root
|
|
63
64
|
if (
|
|
64
65
|
!worktreePath.startsWith(allowedPrefix + '/') &&
|
|
65
66
|
worktreePath !== allowedPrefix.slice(0, -1)
|
|
@@ -67,7 +68,7 @@ export const enterWorktreeTool: ToolDefinition = {
|
|
|
67
68
|
return {
|
|
68
69
|
success: false,
|
|
69
70
|
content: '',
|
|
70
|
-
error: 'Worktree path must be within .
|
|
71
|
+
error: 'Worktree path must be within .mipham/worktrees/.',
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
import { worktreeRoots } from '../../core/paths.ts'
|
|
2
3
|
|
|
3
4
|
export const exitWorktreeTool: ToolDefinition = {
|
|
4
5
|
name: 'ExitWorktree',
|
|
@@ -13,7 +14,8 @@ export const exitWorktreeTool: ToolDefinition = {
|
|
|
13
14
|
properties: {
|
|
14
15
|
path: {
|
|
15
16
|
type: 'string',
|
|
16
|
-
description:
|
|
17
|
+
description:
|
|
18
|
+
'Absolute path to the worktree to exit. Must be under .mipham/worktrees/ (or the legacy .claude/worktrees/).',
|
|
17
19
|
},
|
|
18
20
|
action: {
|
|
19
21
|
type: 'string',
|
|
@@ -34,18 +36,21 @@ export const exitWorktreeTool: ToolDefinition = {
|
|
|
34
36
|
const action = params.action as string
|
|
35
37
|
const discardChanges = params.discard_changes === true
|
|
36
38
|
|
|
37
|
-
// Validate the path is under .claude
|
|
39
|
+
// Validate the path is under a worktree root(新目录优先,兼容历史 .claude/)
|
|
38
40
|
const cwd = ctx.cwd
|
|
39
41
|
const { resolve } = await import('node:path')
|
|
40
42
|
const resolvedPath = resolve(worktreePath)
|
|
41
|
-
const
|
|
43
|
+
const roots = worktreeRoots(cwd).map((root) => resolve(root))
|
|
44
|
+
const inWorktree = roots.some(
|
|
45
|
+
(root) => resolvedPath === root || resolvedPath.startsWith(root + '/'),
|
|
46
|
+
)
|
|
42
47
|
|
|
43
|
-
if (!
|
|
48
|
+
if (!inWorktree) {
|
|
44
49
|
return {
|
|
45
50
|
success: false,
|
|
46
51
|
content: '',
|
|
47
52
|
error:
|
|
48
|
-
`Path "${worktreePath}" is not under .claude/worktrees/. ` +
|
|
53
|
+
`Path "${worktreePath}" is not under .mipham/worktrees/ or .claude/worktrees/. ` +
|
|
49
54
|
`Only worktrees created by EnterWorktree can be managed here.`,
|
|
50
55
|
}
|
|
51
56
|
}
|
package/src/tools/exec/git.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
1
2
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
3
|
+
import { findWorktreeMarker } from '../../core/paths.ts'
|
|
4
|
+
import { isWithin } from '../../security/path.ts'
|
|
2
5
|
|
|
3
6
|
// P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
|
|
4
7
|
// fragile substring matching. Each pattern describes what it blocks.
|
|
@@ -52,21 +55,33 @@ export const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: strin
|
|
|
52
55
|
* when operating in a worktree context.
|
|
53
56
|
*/
|
|
54
57
|
function isOutsideWorktree(command: string, cwd: string): string | null {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
// 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,隔离度只增不减。
|
|
59
|
+
const marker = findWorktreeMarker(cwd)
|
|
60
|
+
if (!marker) return null
|
|
61
|
+
|
|
62
|
+
// Extract the project root (everything before the worktree marker)
|
|
63
|
+
const worktreeRoot = marker.root
|
|
64
|
+
|
|
65
|
+
// Detect git commands that reference the main checkout path.
|
|
66
|
+
// NOTE: no `\b` before the leading `-` — `\b` needs a word/non-word
|
|
67
|
+
// transition and `-` is itself a non-word char, so `\b--work-tree=` never
|
|
68
|
+
// matched anything and this whole check was silently dead. `-C` needs an
|
|
69
|
+
// explicit boundary because without one it would match inside a path.
|
|
70
|
+
const mainCheckoutPaths = [
|
|
71
|
+
/--work-tree=([^\s]+)/g,
|
|
72
|
+
/--git-dir=([^\s]+)/g,
|
|
73
|
+
/(?:^|\s)-C\s+([^\s]+)/g,
|
|
74
|
+
]
|
|
63
75
|
|
|
64
76
|
for (const pathPattern of mainCheckoutPaths) {
|
|
65
77
|
let match: RegExpExecArray | null
|
|
66
78
|
while ((match = pathPattern.exec(command)) !== null) {
|
|
67
79
|
const refPath = match[1]!
|
|
68
|
-
//
|
|
69
|
-
|
|
80
|
+
// 归一后按**路径分段**判归属,不用字符串前缀:此前 `refPath.startsWith(cwd)`
|
|
81
|
+
// 从不解析 `..`,`--work-tree=/proj/../etc` 因为「以 /proj/ 开头」被放行,
|
|
82
|
+
// 而 git 拿到的是 /etc。判据与 Bash 守卫(resolveWorktreeEscape)同一套。
|
|
83
|
+
const resolved = resolve(cwd, refPath)
|
|
84
|
+
if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) {
|
|
70
85
|
return `Git command references path outside worktree: ${refPath}`
|
|
71
86
|
}
|
|
72
87
|
}
|
package/src/tools/file/grep.ts
CHANGED
|
@@ -16,17 +16,23 @@ export async function runSearch(
|
|
|
16
16
|
cmd: string[],
|
|
17
17
|
cwd: string,
|
|
18
18
|
timeoutMs: number,
|
|
19
|
-
): Promise<{ stdout: string; timedOut: boolean; exitCode: number | null }> {
|
|
19
|
+
): Promise<{ stdout: string; stderr: string; timedOut: boolean; exitCode: number | null }> {
|
|
20
20
|
const proc = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
21
21
|
let timedOut = false
|
|
22
22
|
const timer = setTimeout(() => {
|
|
23
23
|
timedOut = true
|
|
24
24
|
proc.kill()
|
|
25
25
|
}, timeoutMs)
|
|
26
|
-
|
|
26
|
+
// 两条管道必须**并发**读。先读满 stdout 再读 stderr 会在子进程写满
|
|
27
|
+
// stderr(~64 KB 管道缓冲)时死锁:它阻塞在 write 上不退出,stdout 也就
|
|
28
|
+
// 永远读不到 EOF —— 只能等超时兜底,而超时会把「慢」和「错」说成同一件事。
|
|
29
|
+
const [stdout, stderr] = await Promise.all([
|
|
30
|
+
new Response(proc.stdout).text(),
|
|
31
|
+
new Response(proc.stderr).text(),
|
|
32
|
+
])
|
|
27
33
|
await proc.exited
|
|
28
34
|
clearTimeout(timer)
|
|
29
|
-
return { stdout, timedOut, exitCode: proc.exitCode }
|
|
35
|
+
return { stdout, stderr, timedOut, exitCode: proc.exitCode }
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/** grep 输出上限:超过则显式截断并附标记(不能静默丢内容——模型会误以为看全了)。 */
|
|
@@ -107,7 +113,9 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
107
113
|
if (exitCode === 0) {
|
|
108
114
|
return {
|
|
109
115
|
success: true,
|
|
110
|
-
content:
|
|
116
|
+
content: truncateGrepOutput(
|
|
117
|
+
maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
|
|
118
|
+
),
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
121
|
// rg exit 2 (error, e.g. permission denied on protected dirs) — do NOT
|
|
@@ -117,12 +125,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
117
125
|
if (stdout && stdout.trim()) {
|
|
118
126
|
return {
|
|
119
127
|
success: true,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
credentialConfig,
|
|
124
|
-
'
|
|
125
|
-
),
|
|
128
|
+
// 先截断正文、再拼注解:注解拼在截断之内的话,它自己会被切掉,
|
|
129
|
+
// 模型拿到的就是一句没头没尾的提示。
|
|
130
|
+
content:
|
|
131
|
+
truncateGrepOutput(maskSearchOutput(stdout, credentialConfig, 'heading')) +
|
|
132
|
+
'\n\n(rg exited 2 — some paths unreadable; narrow scope for complete results)',
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
return {
|
|
@@ -153,7 +160,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
153
160
|
'+',
|
|
154
161
|
]
|
|
155
162
|
try {
|
|
156
|
-
const { stdout, timedOut, exitCode } = await runSearch(
|
|
163
|
+
const { stdout, stderr, timedOut, exitCode } = await runSearch(
|
|
164
|
+
grepArgs,
|
|
165
|
+
ctx.cwd,
|
|
166
|
+
GREP_TIMEOUT_MS,
|
|
167
|
+
)
|
|
157
168
|
if (timedOut) {
|
|
158
169
|
return {
|
|
159
170
|
success: false,
|
|
@@ -161,7 +172,17 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
161
172
|
error: `Grep timed out after ${GREP_TIMEOUT_MS / 1000}s — narrow scope with "path" and "include".`,
|
|
162
173
|
}
|
|
163
174
|
}
|
|
164
|
-
|
|
175
|
+
// 退出码 1 在 find 这里是**两件事**:真的没搜到,和「根本没搜成」
|
|
176
|
+
// —— 目录不可读、grep 正则非法、grep 不在 PATH,BSD find 全都退 1。
|
|
177
|
+
// 后者一律伴随 stderr,所以用 stderr 而非退出码分辨;不加这一刀,
|
|
178
|
+
// 模型会被告知「没有匹配」,而真相是这次搜索压根没跑起来。
|
|
179
|
+
if (exitCode === 1) {
|
|
180
|
+
const err = stderr.trim()
|
|
181
|
+
if (err) {
|
|
182
|
+
return { success: false, content: '', error: `Search failed: ${err.slice(0, 500)}` }
|
|
183
|
+
}
|
|
184
|
+
return { success: true, content: '(no matches)' }
|
|
185
|
+
}
|
|
165
186
|
if (exitCode === 0) {
|
|
166
187
|
return {
|
|
167
188
|
success: true,
|
|
@@ -171,7 +192,10 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
171
192
|
return {
|
|
172
193
|
success: false,
|
|
173
194
|
content: '',
|
|
174
|
-
error:
|
|
195
|
+
error:
|
|
196
|
+
`grep failed (exit ${exitCode})` +
|
|
197
|
+
(stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : '') +
|
|
198
|
+
'. Install ripgrep: brew install ripgrep',
|
|
175
199
|
}
|
|
176
200
|
} catch {
|
|
177
201
|
return {
|