@miphamai/cli 0.81.7 → 0.81.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -163,7 +163,9 @@ export class SessionLog {
163
163
  const trimmed = line.trim()
164
164
  if (!trimmed) continue
165
165
  try {
166
- log.buf.push(JSON.parse(trimmed) as SessionEvent)
166
+ const parsed: unknown = JSON.parse(trimmed)
167
+ if (!isValidEvent(parsed)) continue
168
+ log.buf.push(parsed)
167
169
  } catch {
168
170
  // 跳过损坏行
169
171
  }
@@ -173,6 +175,47 @@ export class SessionLog {
173
175
  }
174
176
  }
175
177
 
178
+ /**
179
+ * 事件结构校验 —— 磁盘→内存的**唯一**入口(`open()`)用它挡掉坏行。
180
+ *
181
+ * 只校验 `deriveMessages` 会解引用的字段:这不是 schema 校验,是「投影不许崩」的
182
+ * 最低门槛。**合法 JSON 不等于合法事件** —— `null`、`{"type":"user/message"}`(无
183
+ * message)、`{"type":"compaction/rewrite"}`(无 messages)都过得了 `JSON.parse`,
184
+ * 却让投影抛 TypeError(rewrite 那条更狠:`out` 直接变 `undefined`,下一条就炸),
185
+ * 而 `/resume` 那条链上没有 try 兜它。坏行来自手写/拼接/半截重排,不是本进程写的。
186
+ *
187
+ * 不校验 `session/start`、`assistant/chunk`、`checker/decision`:投影对它们无分支;
188
+ * 未知类型一律放行(前向兼容 —— 认不出来不等于要销毁它)。
189
+ */
190
+ function isValidEvent(e: unknown): e is SessionEvent {
191
+ if (!e || typeof e !== 'object') return false
192
+ const ev = e as Record<string, unknown>
193
+ if (typeof ev.type !== 'string') return false
194
+ switch (ev.type) {
195
+ case 'user/message':
196
+ case 'assistant/message':
197
+ return !!ev.message && typeof ev.message === 'object'
198
+ case 'tool/call':
199
+ return (
200
+ typeof ev.id === 'string' &&
201
+ typeof ev.name === 'string' &&
202
+ !!ev.input &&
203
+ typeof ev.input === 'object'
204
+ )
205
+ case 'tool/result':
206
+ // 只要求 id:`result`(新格式)与 `content`(旧 JSONL)都可缺省,派生侧已兜底
207
+ return typeof ev.id === 'string'
208
+ case 'context/inject':
209
+ return typeof ev.text === 'string'
210
+ case 'compaction/summary':
211
+ return typeof ev.summary === 'string'
212
+ case 'compaction/rewrite':
213
+ return Array.isArray(ev.messages)
214
+ default:
215
+ return true
216
+ }
217
+ }
218
+
176
219
  const SUMMARY_PREFIX = '[Earlier conversation summary]:'
177
220
 
178
221
  export function isCompactionSummary(m: Message): boolean {
@@ -212,6 +212,11 @@ export class SessionStore {
212
212
 
213
213
  /**
214
214
  * List all saved sessions, most recent first.
215
+ *
216
+ * 单个文件读不出来只赔上它自己 —— 这里是为**逐文件**兜底,不是给整个列表兜底:
217
+ * 从前 try 包住整个 for 循环,第一个抛异常的文件就让 `/resume` 一条会话都不显示,
218
+ * 而其余文件全是好的。读不出来的(坏结构、半截写、I/O 错)直接跳过:连 metadata
219
+ * 都建不出来的会话没法在列表里表示。
215
220
  */
216
221
  static list(): SessionMetadata[] {
217
222
  ensureDir()
@@ -220,7 +225,12 @@ export class SessionStore {
220
225
  const sessions: SessionMetadata[] = []
221
226
  for (const file of files) {
222
227
  const name = file.replace('.jsonl', '')
223
- const session = SessionStore.load(name)
228
+ let session: StoredSession | null = null
229
+ try {
230
+ session = SessionStore.load(name)
231
+ } catch {
232
+ continue
233
+ }
224
234
  if (session?.metadata) {
225
235
  sessions.push(session.metadata)
226
236
  }
@@ -126,6 +126,9 @@ export class SessionWorker {
126
126
  let totalInputTokens = 0
127
127
  let totalOutputTokens = 0
128
128
  let stopReason: string = 'end_turn'
129
+ // The provider hit its output ceiling at least once in this turn. Reported only
130
+ // when nothing worse happened — the three original values keep their precedence.
131
+ let truncated = false
129
132
 
130
133
  try {
131
134
  for await (const chunk of this.engine.process(prompt, signal)) {
@@ -152,6 +155,13 @@ export class SessionWorker {
152
155
  if (chunk.outputTokens) totalOutputTokens += chunk.outputTokens
153
156
  }
154
157
 
158
+ // A turn cut off at the provider's output ceiling must not be reported as a
159
+ // clean finish — downstream (the benchmark driver reads stopReason) would
160
+ // read a truncated trial as a legitimate ending.
161
+ if (chunk.type === 'stop' && chunk.truncated) {
162
+ truncated = true
163
+ }
164
+
155
165
  // Deliberately NO break on 'stop' — the chunk type is overloaded here.
156
166
  // Providers emit a provider-level 'stop' unconditionally at the end of
157
167
  // EVERY LLM stream, including tool-call turns; the engine still has to
@@ -169,6 +179,11 @@ export class SessionWorker {
169
179
  stopReason = 'interrupted'
170
180
  }
171
181
 
182
+ // ── Truncation is the weakest signal: interrupted / error still win ──
183
+ if (truncated && stopReason === 'end_turn') {
184
+ stopReason = 'output_limit'
185
+ }
186
+
172
187
  // Step 4: Persist assistant response and finalize
173
188
  if (assistantContent) {
174
189
  // Save the final (or partial) assistant message
package/src/index.tsx CHANGED
@@ -53,7 +53,8 @@ import { initTelemetry, enableTelemetryNow } from './telemetry/index'
53
53
  import { wasPrompted, markPrompted, isInteractive, setTelemetryEnabled } from './telemetry/consent'
54
54
  import { officialEndpointHost } from './telemetry/endpoint'
55
55
  import { getWorkspaceTrust } from './core/workspace-trust'
56
- import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
56
+ import { ARTIFACT_PORT } from './shared/constants'
57
+ import { artifactsRoot } from './artifacts/paths'
57
58
  import { AgentViewManager } from './agent-view/agent-view-manager'
58
59
  import { AgentViewDashboard } from './agent-view/dashboard'
59
60
  import { createT } from './i18n-core/t'
@@ -598,7 +599,7 @@ export async function runApp(options: RunOptions): Promise<void> {
598
599
  }
599
600
 
600
601
  // Start artifact server (lazy — first artifact creation triggers listening)
601
- const artifactsDir = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
602
+ const artifactsDir = artifactsRoot(process.cwd())
602
603
  const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
603
604
 
604
605
  // Create query engine
@@ -9,7 +9,7 @@ import {
9
9
  } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
- import { execSync } from 'node:child_process'
12
+ import { execFileSync } from 'node:child_process'
13
13
  import { validatePlugin } from './plugin-validator'
14
14
 
15
15
  const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
@@ -110,11 +110,22 @@ export class PluginManager {
110
110
  'utf-8',
111
111
  )
112
112
 
113
- execSync(`npm install ${packageName} --prefix "${stagingDir}" --no-save`, {
114
- encoding: 'utf-8',
115
- stdio: 'pipe',
116
- timeout: 60_000,
117
- })
113
+ // `--ignore-scripts`: without it, installing a plugin runs that package's
114
+ // preinstall/install/postinstall hooks as the current user — arbitrary
115
+ // code execution from any npm package, reachable via `/install-plugin`.
116
+ // A plugin only needs to *be* files on disk; it never needs a build step
117
+ // of its own to be loaded from here. `execFileSync` (argv array, no
118
+ // shell) keeps the command line independent of packageName, so package
119
+ // name validation is not the only thing standing between us and a shell.
120
+ execFileSync(
121
+ 'npm',
122
+ ['install', packageName, '--prefix', stagingDir, '--no-save', '--ignore-scripts'],
123
+ {
124
+ encoding: 'utf-8',
125
+ stdio: 'pipe',
126
+ timeout: 60_000,
127
+ },
128
+ )
118
129
 
119
130
  // npm installs the package into <stagingDir>/node_modules/<packageName>/ —
120
131
  // validate there, then flatten it to the plugin dir root so its layout
@@ -33,6 +33,7 @@ interface AnthropicSSEEvent {
33
33
  text?: string
34
34
  thinking?: string
35
35
  partial_json?: string
36
+ stop_reason?: string | null
36
37
  }
37
38
  error?: { type: string; message: string }
38
39
  usage?: { input_tokens: number; output_tokens: number }
@@ -52,12 +53,21 @@ export class AnthropicProvider implements ProviderInstance {
52
53
  let currentToolId = ''
53
54
  let accumulatedToolInput = ''
54
55
 
56
+ // Set when the provider reports `stop_reason: 'max_tokens'` — the turn was cut
57
+ // off at the output ceiling rather than ended by the model.
58
+ let truncated = false
59
+
55
60
  const messages = this.convertMessages(req.messages)
56
61
  this.markPrefixCacheBreakpoint(messages)
57
62
 
63
+ // Priority: explicit request override (summarizer / sub-agent call sites) >
64
+ // the model's declared ceiling > 4096. The fallback stays: a model id that
65
+ // isn't in `config.models` has no known ceiling.
66
+ const declaredMaxOutput = this.config.models.find((m) => m.id === req.model)?.maxOutput
67
+
58
68
  const body: Record<string, unknown> = {
59
69
  model: req.model,
60
- max_tokens: req.maxTokens || 4096,
70
+ max_tokens: req.maxTokens || declaredMaxOutput || 4096,
61
71
  stream: true,
62
72
  messages,
63
73
  }
@@ -184,6 +194,13 @@ export class AnthropicProvider implements ProviderInstance {
184
194
  }
185
195
 
186
196
  case 'content_block_stop': {
197
+ // 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
198
+ // `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
199
+ // 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
200
+ // tool_call」的处置,这里结构上做不到(它的 finish_reason 与
201
+ // tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
202
+ // 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
203
+ // 那是一次行为变更,不属本次范围。
187
204
  if (currentToolId && currentToolName && accumulatedToolInput) {
188
205
  let parsedInput: Record<string, unknown> = {}
189
206
  try {
@@ -223,11 +240,18 @@ export class AnthropicProvider implements ProviderInstance {
223
240
  if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
224
241
  accumulatedToolInput += event.delta.partial_json
225
242
  }
243
+ // `max_tokens` means the turn hit the output ceiling. Without this the
244
+ // truncation is indistinguishable from `end_turn`: both arrive here and
245
+ // the terminal stop below looks the same either way.
246
+ const stopReason = event.delta?.stop_reason
247
+ if (stopReason === 'max_tokens') {
248
+ truncated = true
249
+ }
226
250
  break
227
251
  }
228
252
 
229
253
  case 'message_stop': {
230
- yield { type: 'stop' }
254
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
231
255
  return
232
256
  }
233
257
 
@@ -17,11 +17,16 @@ export class OpenAICompatProvider implements ProviderInstance {
17
17
  ) || 'https://api.openai.com/v1'
18
18
  const apiKey = this.resolveApiKey(this.config.apiKey)
19
19
 
20
+ // Priority: explicit request override (summarizer / sub-agent call sites) >
21
+ // the model's declared ceiling > 8192. The fallback stays: local `ollama`
22
+ // model ids are not in `config.models`, so their real ceiling is unknown.
23
+ const declaredMaxOutput = this.config.models.find((m) => m.id === req.model)?.maxOutput
24
+
20
25
  const body = {
21
26
  model: req.model,
22
27
  messages: this.convertMessages(req.messages, req.systemPrompt),
23
28
  stream: true,
24
- max_tokens: req.maxTokens || 8192,
29
+ max_tokens: req.maxTokens || declaredMaxOutput || 8192,
25
30
  temperature: req.temperature,
26
31
  tools: req.tools?.map((t) => ({ type: 'function', function: t })),
27
32
  }
@@ -173,6 +178,14 @@ export class OpenAICompatProvider implements ProviderInstance {
173
178
  if (choice.finish_reason === 'stop') {
174
179
  yield { type: 'stop', reasoning_content: reasoningContent }
175
180
  }
181
+
182
+ if (choice.finish_reason === 'length') {
183
+ // Truncated: the accumulated tool calls were cut off mid-arguments, so
184
+ // their JSON is incomplete. Drop them rather than dispatching a broken
185
+ // call, and clear the map so the `[DONE]` handler can't emit them either.
186
+ pendingToolCalls.clear()
187
+ yield { type: 'stop', reasoning_content: reasoningContent, truncated: true }
188
+ }
176
189
  } catch {
177
190
  // skip unparseable chunks
178
191
  }
@@ -113,8 +113,13 @@ function findExistingParent(p: string): string | undefined {
113
113
 
114
114
  /**
115
115
  * Check if `child` is within `parent` (or equal to it).
116
+ *
117
+ * Compares path segments, not string prefixes: `/a/b-evil` is NOT within
118
+ * `/a/b`. Both sides are expected to be resolved (no `..`, no trailing slash
119
+ * beyond the root). The trailing-slash normalization below is for callers that
120
+ * hand in an unresolved string such as `/proj/src/`.
116
121
  */
117
- function isWithin(child: string, parent: string): boolean {
122
+ export function isWithin(child: string, parent: string): boolean {
118
123
  // Normalize trailing slashes for comparison
119
124
  const c = child.endsWith('/') ? child.slice(0, -1) : child
120
125
  const p = parent.endsWith('/') ? parent.slice(0, -1) : parent
@@ -1,17 +1,40 @@
1
- import { writeFileSync, renameSync } from 'node:fs'
1
+ import { writeFileSync, renameSync, unlinkSync } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
2
3
 
3
4
  /**
4
- * Write a file atomically: write to a same-directory `.tmp` file, then rename
5
+ * Write a file atomically: write to a same-directory temp file, then rename
5
6
  * over the target. Same-filesystem rename is atomic, so a crash or kill
6
7
  * mid-write can never leave a truncated/corrupt file — readers see either the
7
8
  * old or the new content, never a partial write.
9
+ *
10
+ * The temp name carries the pid and a random suffix rather than being a fixed
11
+ * `path + '.tmp'`. Several callers write to *shared* locations (the telemetry
12
+ * queue on every process exit, skill usage, the CRSI ledger), so two sessions or
13
+ * daemon workers on one machine can be inside this function for the same path at
14
+ * the same time. With one shared temp name that interleaving loses a write (the
15
+ * first rename moves the *second* writer's content into place) and then throws
16
+ * ENOENT on the other writer's rename — in a function whose whole point is that
17
+ * the target is never observed half-written. Same directory is still required:
18
+ * rename is only atomic within a filesystem.
8
19
  */
9
20
  export function atomicWriteFileSync(
10
21
  path: string,
11
22
  content: string,
12
23
  options: { mode?: number } = {},
13
24
  ): void {
14
- const tmp = path + '.tmp'
15
- writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
16
- renameSync(tmp, path)
25
+ const tmp = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`
26
+ try {
27
+ writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
28
+ renameSync(tmp, path)
29
+ } catch (err) {
30
+ // Clean up on the failure path: a fixed temp name used to be overwritten by
31
+ // the next writer, but unique names mean every abandoned write would leave
32
+ // its own orphan forever — nothing else ever sweeps this directory.
33
+ try {
34
+ unlinkSync(tmp)
35
+ } catch {
36
+ // Already renamed away (or never created) — nothing to clean.
37
+ }
38
+ throw err
39
+ }
17
40
  }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.81.7' as const
12
+ export const PACKAGE_VERSION = '0.81.8' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -138,6 +138,15 @@ export interface StreamChunk {
138
138
  inputTokens?: number
139
139
  /** API-reported output token count (type: 'usage'). */
140
140
  outputTokens?: number
141
+ /**
142
+ * The provider stopped because it hit the output token ceiling
143
+ * (OpenAI `finish_reason: 'length'` / Anthropic `stop_reason: 'max_tokens'`).
144
+ * Set **only when true** — absent on every normal stop, so the success path
145
+ * stays byte-identical. Without it a truncated turn is indistinguishable
146
+ * from a turn the model chose to end: the provider emits its terminal stop
147
+ * either way, and tool calls cut off mid-arguments are dropped silently.
148
+ */
149
+ truncated?: boolean
141
150
  }
142
151
 
143
152
  // ── Config Types ──
@@ -1,7 +1,8 @@
1
1
  import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { ToolDefinition } from '../../shared/index.ts'
4
- import { ARTIFACTS_DIR, ARTIFACT_MAX_SIZE } from '../../shared/constants'
4
+ import { ARTIFACT_MAX_SIZE } from '../../shared/constants'
5
+ import { artifactsRoot } from '../../artifacts/paths'
5
6
  import { addToManifest, readManifest, archiveVersion } from '../../artifacts/manifest'
6
7
 
7
8
  const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/
@@ -57,8 +58,10 @@ export const artifactTool: ToolDefinition = {
57
58
  }
58
59
  }
59
60
 
60
- // Determine output paths
61
- const baseDir = join(ctx.cwd, ARTIFACTS_DIR)
61
+ // Determine output paths — the server's root, the manifest's home and this
62
+ // directory are one and the same; computing it here is what made the URL a
63
+ // guaranteed 404 before.
64
+ const baseDir = artifactsRoot(ctx.cwd)
62
65
  const sessionDir = join(baseDir, ctx.sessionId)
63
66
  mkdirSync(sessionDir, { recursive: true })
64
67
 
@@ -100,7 +103,7 @@ export const artifactTool: ToolDefinition = {
100
103
  const prev = manifestPre.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId)
101
104
  const versionCount = prev?.versionCount || (isUpdate ? 1 : undefined)
102
105
 
103
- addToManifest(
106
+ const { quarantined } = addToManifest(
104
107
  baseDir,
105
108
  {
106
109
  name,
@@ -124,6 +127,12 @@ export const artifactTool: ToolDefinition = {
124
127
  const galleryUrl = port ? `http://localhost:${port}` : undefined
125
128
  const versionLine = archivedVersion ? ` Prev archived as: ${archivedVersion}` : ''
126
129
  const galleryLine = galleryUrl ? `Gallery: ${galleryUrl}` : ''
130
+ // The index was unreadable and got moved aside, so this publish started from
131
+ // nothing: say it here, or the user reads "saved" and never learns that the
132
+ // rest of the index is now a file next to it.
133
+ const warnLine = quarantined
134
+ ? ` ⚠️ Index was unreadable; previous index kept at ${quarantined}`
135
+ : ''
127
136
 
128
137
  return {
129
138
  success: true,
@@ -132,6 +141,7 @@ export const artifactTool: ToolDefinition = {
132
141
  ` URL: ${url}`,
133
142
  ` Size: ${size.toLocaleString()} bytes`,
134
143
  versionLine,
144
+ warnLine,
135
145
  galleryLine,
136
146
  '',
137
147
  `Open in browser: /artifact open ${name}`,
@@ -1,7 +1,8 @@
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'
5
6
  import { findWorktreeMarker } from '../../core/paths.ts'
6
7
  import type { Service } from '../../vajra'
7
8
  import { toolKey } from '../seam'
@@ -308,6 +309,36 @@ function parseErrorLocations(stderr: string): ErrorLocation[] {
308
309
  return unique.slice(0, 10)
309
310
  }
310
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
+
311
342
  export function createBashTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
312
343
  return {
313
344
  name: 'Bash',
@@ -339,23 +370,14 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
339
370
  // 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
340
371
  const worktreeMarker = findWorktreeMarker(ctx.cwd)
341
372
  if (worktreeMarker) {
342
- // Detect cd to absolute paths outside the worktree
343
- const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
344
- const cdMatch = command.match(cdEscapePattern)
345
- if (cdMatch) {
346
- const target = cdMatch[1] || cdMatch[2] || cdMatch[3] || ''
347
- // Resolve relative to cwd
348
- const resolved = target.startsWith('/')
349
- ? target
350
- : `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
351
- if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeMarker.root + '/')) {
352
- return {
353
- success: false,
354
- content: '',
355
- error:
356
- `Worktree isolation: cannot cd outside worktree directory. ` +
357
- `Attempted: ${target}. Use tools within the worktree only.`,
358
- }
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.`,
359
381
  }
360
382
  }
361
383
  }
@@ -1,5 +1,7 @@
1
+ import { resolve } from 'node:path'
1
2
  import type { ToolDefinition } from '../../shared/index.ts'
2
3
  import { findWorktreeMarker } from '../../core/paths.ts'
4
+ import { isWithin } from '../../security/path.ts'
3
5
 
4
6
  // P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
5
7
  // fragile substring matching. Each pattern describes what it blocks.
@@ -75,8 +77,11 @@ function isOutsideWorktree(command: string, cwd: string): string | null {
75
77
  let match: RegExpExecArray | null
76
78
  while ((match = pathPattern.exec(command)) !== null) {
77
79
  const refPath = match[1]!
78
- // If the referenced path is outside the worktree, block it
79
- if (!refPath.startsWith(cwd) && !refPath.startsWith(worktreeRoot + '/')) {
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)) {
80
85
  return `Git command references path outside worktree: ${refPath}`
81
86
  }
82
87
  }
@@ -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
- const stdout = await new Response(proc.stdout).text()
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: maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
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
- content: maskSearchOutput(
121
- stdout +
122
- '\n\n(rg exited 2 — some paths unreadable; narrow scope for complete results)',
123
- credentialConfig,
124
- 'heading',
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(grepArgs, ctx.cwd, GREP_TIMEOUT_MS)
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
- if (exitCode === 1) return { success: true, content: '(no matches)' }
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: 'grep failed. Install ripgrep: brew install ripgrep',
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 {