@miphamai/cli 0.81.7 → 0.81.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/bin/mipham.ts +35 -1
  3. package/package.json +1 -1
  4. package/src/agent/message-bus.ts +10 -3
  5. package/src/agent/sub-agent.ts +60 -12
  6. package/src/agent/types.ts +14 -1
  7. package/src/artifacts/manifest.ts +90 -34
  8. package/src/artifacts/paths.ts +19 -0
  9. package/src/artifacts/server.ts +48 -8
  10. package/src/config/credential-crypto.ts +28 -5
  11. package/src/config/defaults.ts +18 -10
  12. package/src/config/keys-manager.ts +14 -9
  13. package/src/config/loader.ts +202 -63
  14. package/src/config/preferences.ts +5 -2
  15. package/src/core/credential-masker/output-scrub.ts +16 -2
  16. package/src/core/cron-poller.ts +30 -6
  17. package/src/core/engine.ts +7 -2
  18. package/src/core/hooks-executor.ts +30 -2
  19. package/src/core/hooks.ts +51 -4
  20. package/src/core/paths.ts +44 -1
  21. package/src/core/permission-config.ts +146 -14
  22. package/src/core/permission-rules.ts +157 -6
  23. package/src/core/permission.ts +81 -13
  24. package/src/core/rules-loader.ts +35 -5
  25. package/src/core/session-log.ts +49 -2
  26. package/src/core/session-store.ts +11 -1
  27. package/src/core/workspace-trust.ts +42 -4
  28. package/src/daemon/auth.ts +15 -14
  29. package/src/daemon/engine-capabilities.ts +12 -2
  30. package/src/daemon/remote-engine.ts +9 -4
  31. package/src/daemon/server.ts +29 -1
  32. package/src/daemon/session-worker.ts +15 -0
  33. package/src/i18n-core/locales/en-US.json +12 -8
  34. package/src/i18n-core/locales/zh-CN.json +12 -8
  35. package/src/index.tsx +47 -19
  36. package/src/mcp/client.ts +24 -0
  37. package/src/mcp/http-transport.ts +35 -3
  38. package/src/plugin/plugin-manager.ts +30 -8
  39. package/src/providers/anthropic.ts +74 -13
  40. package/src/providers/openai-compat.ts +14 -1
  41. package/src/security/gate.ts +18 -0
  42. package/src/security/path.ts +25 -2
  43. package/src/shared/arg-validation.ts +37 -2
  44. package/src/shared/atomic-write.ts +28 -5
  45. package/src/shared/package-info.ts +1 -1
  46. package/src/shared/sanitize.ts +27 -2
  47. package/src/shared/types.ts +17 -0
  48. package/src/shared/update.ts +22 -5
  49. package/src/tools/agent/agent.ts +3 -0
  50. package/src/tools/artifact/artifact.ts +14 -4
  51. package/src/tools/exec/bash.ts +146 -24
  52. package/src/tools/exec/enter-worktree.ts +9 -3
  53. package/src/tools/exec/exit-worktree.ts +6 -3
  54. package/src/tools/exec/git.ts +83 -3
  55. package/src/tools/file/glob.ts +19 -3
  56. package/src/tools/file/grep.ts +70 -16
  57. package/src/tools/file/read.ts +151 -45
  58. package/src/tools/index.ts +12 -4
  59. package/src/tools/scheduling/cron.ts +34 -5
  60. package/src/tools/system/config.ts +6 -2
  61. package/src/ui/app.tsx +47 -11
  62. package/src/ui/commands.ts +187 -41
  63. package/src/workflow/primitives/agent.ts +4 -0
  64. package/src/artifacts/versioning.ts +0 -127
@@ -1,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: '',
@@ -1,5 +1,10 @@
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'
5
+
6
+ /** Generous enough for a clone or fetch, short enough to bound a hung git. */
7
+ const GIT_TIMEOUT_MS = 120_000
3
8
 
4
9
  // P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
5
10
  // fragile substring matching. Each pattern describes what it blocks.
@@ -75,8 +80,11 @@ function isOutsideWorktree(command: string, cwd: string): string | null {
75
80
  let match: RegExpExecArray | null
76
81
  while ((match = pathPattern.exec(command)) !== null) {
77
82
  const refPath = match[1]!
78
- // If the referenced path is outside the worktree, block it
79
- if (!refPath.startsWith(cwd) && !refPath.startsWith(worktreeRoot + '/')) {
83
+ // 归一后按**路径分段**判归属,不用字符串前缀:此前 `refPath.startsWith(cwd)`
84
+ // 从不解析 `..`,`--work-tree=/proj/../etc` 因为「以 /proj/ 开头」被放行,
85
+ // 而 git 拿到的是 /etc。判据与 Bash 守卫(resolveWorktreeEscape)同一套。
86
+ const resolved = resolve(cwd, refPath)
87
+ if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) {
80
88
  return `Git command references path outside worktree: ${refPath}`
81
89
  }
82
90
  }
@@ -85,6 +93,60 @@ function isOutsideWorktree(command: string, cwd: string): string | null {
85
93
  return null
86
94
  }
87
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
+
88
150
  /**
89
151
  * Split a git command string into argv tokens, honoring shell-style quoting
90
152
  * (single quotes, double quotes, and backslash escapes). Unlike a naive
@@ -178,14 +240,32 @@ export const gitTool: ToolDefinition = {
178
240
  }
179
241
  }
180
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
+
181
256
  try {
182
- const proc = Bun.spawn(['git', ...splitCommand(command)], {
257
+ const proc = Bun.spawn(['git', ...argv], {
183
258
  cwd: ctx.cwd,
184
259
  stdout: 'pipe',
185
260
  stderr: 'pipe',
186
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)
187
266
  const output = await new Response(proc.stdout).text()
188
267
  const exitCode = await proc.exited
268
+ clearTimeout(timer)
189
269
 
190
270
  if (exitCode !== 0) {
191
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
  }
@@ -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 输出上限:超过则显式截断并附标记(不能静默丢内容——模型会误以为看全了)。 */
@@ -48,6 +54,27 @@ export function isTopLevelScope(searchPath: string, home = homedir()): boolean {
48
54
  return searchPath === home || searchPath === parse(searchPath).root
49
55
  }
50
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
+
51
78
  export function createGrepTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
52
79
  return {
53
80
  name: 'Grep',
@@ -107,7 +134,9 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
107
134
  if (exitCode === 0) {
108
135
  return {
109
136
  success: true,
110
- content: maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
137
+ content: truncateGrepOutput(
138
+ maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
139
+ ),
111
140
  }
112
141
  }
113
142
  // rg exit 2 (error, e.g. permission denied on protected dirs) — do NOT
@@ -117,12 +146,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
117
146
  if (stdout && stdout.trim()) {
118
147
  return {
119
148
  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
- ),
149
+ // 先截断正文、再拼注解:注解拼在截断之内的话,它自己会被切掉,
150
+ // 模型拿到的就是一句没头没尾的提示。
151
+ content:
152
+ truncateGrepOutput(maskSearchOutput(stdout, credentialConfig, 'heading')) +
153
+ '\n\n(rg exited 2 — some paths unreadable; narrow scope for complete results)',
126
154
  }
127
155
  }
128
156
  return {
@@ -132,7 +160,14 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
132
160
  'rg error (exit 2) — likely permission denied on a large/protected tree. ' +
133
161
  'Narrow scope with "path" (project directory) and "include".',
134
162
  }
135
- } catch {
163
+ } catch (err) {
164
+ if (!isMissingBinary(err)) {
165
+ return {
166
+ success: false,
167
+ content: '',
168
+ error: `ripgrep failed: ${describeError(err)}`,
169
+ }
170
+ }
136
171
  // rg not installed → fall through to grep (find + grep fallback)
137
172
  }
138
173
 
@@ -153,7 +188,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
153
188
  '+',
154
189
  ]
155
190
  try {
156
- const { stdout, timedOut, exitCode } = await runSearch(grepArgs, ctx.cwd, GREP_TIMEOUT_MS)
191
+ const { stdout, stderr, timedOut, exitCode } = await runSearch(
192
+ grepArgs,
193
+ ctx.cwd,
194
+ GREP_TIMEOUT_MS,
195
+ )
157
196
  if (timedOut) {
158
197
  return {
159
198
  success: false,
@@ -161,7 +200,17 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
161
200
  error: `Grep timed out after ${GREP_TIMEOUT_MS / 1000}s — narrow scope with "path" and "include".`,
162
201
  }
163
202
  }
164
- if (exitCode === 1) return { success: true, content: '(no matches)' }
203
+ // 退出码 1 在 find 这里是**两件事**:真的没搜到,和「根本没搜成」
204
+ // —— 目录不可读、grep 正则非法、grep 不在 PATH,BSD find 全都退 1。
205
+ // 后者一律伴随 stderr,所以用 stderr 而非退出码分辨;不加这一刀,
206
+ // 模型会被告知「没有匹配」,而真相是这次搜索压根没跑起来。
207
+ if (exitCode === 1) {
208
+ const err = stderr.trim()
209
+ if (err) {
210
+ return { success: false, content: '', error: `Search failed: ${err.slice(0, 500)}` }
211
+ }
212
+ return { success: true, content: '(no matches)' }
213
+ }
165
214
  if (exitCode === 0) {
166
215
  return {
167
216
  success: true,
@@ -171,13 +220,18 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
171
220
  return {
172
221
  success: false,
173
222
  content: '',
174
- error: 'grep failed. Install ripgrep: brew install ripgrep',
223
+ error:
224
+ `grep failed (exit ${exitCode})` +
225
+ (stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : '') +
226
+ '. Install ripgrep: brew install ripgrep',
175
227
  }
176
- } catch {
228
+ } catch (err) {
229
+ // 这里回退自己起不来 —— 报回退的真实原因,别再断言「rg 没装」
230
+ // (走到这一步的路径本来就有「rg 没装」和「装了但起不来」两种)。
177
231
  return {
178
232
  success: false,
179
233
  content: '',
180
- error: 'grep failed. Install ripgrep: brew install ripgrep',
234
+ error: `Search failed to start: ${describeError(err)}`,
181
235
  }
182
236
  }
183
237
  },
@@ -1,4 +1,4 @@
1
- import { readFileSync, fstatSync, closeSync, constants } from 'node:fs'
1
+ import { readFileSync, readSync, fstatSync, closeSync, constants } from 'node:fs'
2
2
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
4
  import { openNoFollow, isSymlinkLoop } from '../../security/fd'
@@ -6,6 +6,115 @@ import type { Service } from '../../vajra'
6
6
  import { toolKey } from '../seam'
7
7
  import { withValidation } from '../validation'
8
8
 
9
+ /** Lines returned when the caller passes no `limit`. */
10
+ const DEFAULT_LIMIT = 2000
11
+
12
+ /** Bytes per syscall while scanning for newlines. */
13
+ const CHUNK_BYTES = 64 * 1024
14
+
15
+ /**
16
+ * How far a single Read may scan to locate the requested lines. Lines are
17
+ * addressed by index, so finding line N means scanning everything before it;
18
+ * this is what keeps that scan from turning a request for two lines of a 2 GB
19
+ * file into a 2 GB read.
20
+ */
21
+ const MAX_SCAN_BYTES = 50_000_000
22
+
23
+ /** Render a line window the way the tool reports it: ` 123\ttext`. */
24
+ function formatLines(lines: string[], offset: number): string {
25
+ return lines.map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`).join('\n')
26
+ }
27
+
28
+ /**
29
+ * Lines `[offset, offset + limit)` of an already-decoded string.
30
+ *
31
+ * Scans newlines instead of `content.split('\n')`, which materializes one JS
32
+ * string for *every* line of the file in order to return `limit` of them. On a
33
+ * 20 MB source, returning 2000 lines that way cost +34 MB of heap for 60-char
34
+ * lines and +100 MB for 3-char lines (333k vs 10M lines — it tracks line count,
35
+ * not bytes); scanning costs ~0 on top of the string it is handed.
36
+ */
37
+ function windowFromString(content: string, offset: number, limit: number): string[] {
38
+ const out: string[] = []
39
+ let lineNo = 0
40
+ let start = 0
41
+ while (out.length < limit) {
42
+ const nl = content.indexOf('\n', start)
43
+ const end = nl === -1 ? content.length : nl
44
+ if (lineNo >= offset) out.push(content.slice(start, end))
45
+ lineNo++
46
+ if (nl === -1) break
47
+ start = nl + 1
48
+ }
49
+ return out
50
+ }
51
+
52
+ /**
53
+ * Lines `[offset, offset + limit)` read from `fd` — only those lines are ever
54
+ * materialized, so cost tracks the window rather than the file: a 2000-line
55
+ * window of a 20 MB file leaves RSS at the process baseline (~38 MB), where
56
+ * reading the file whole and slicing it cost 142–255 MB.
57
+ *
58
+ * Two passes over the same bytes: the first only *looks* for newline bytes to
59
+ * learn where the window starts and ends, the second reads exactly that byte
60
+ * range and decodes it once. Scanning raw bytes is safe because `0x0a` never
61
+ * occurs inside a multi-byte UTF-8 sequence (continuation bytes are >= 0x80),
62
+ * and the decoded range starts and ends on a newline — so neither pass can split
63
+ * a character. Returns null when the window cannot be located within
64
+ * `MAX_SCAN_BYTES`.
65
+ */
66
+ function windowFromFd(fd: number, offset: number, limit: number): string[] | null {
67
+ if (limit <= 0) return []
68
+
69
+ const buf = Buffer.allocUnsafe(CHUNK_BYTES)
70
+ let scanned = 0 // bytes consumed by the scan
71
+ let lineNo = 0
72
+ let windowStart = offset <= 0 ? 0 : -1
73
+ let windowEnd = -1
74
+ let eof = false
75
+
76
+ while (windowEnd === -1 && scanned < MAX_SCAN_BYTES) {
77
+ const n = readSync(fd, buf, 0, buf.length, scanned)
78
+ if (n <= 0) {
79
+ eof = true
80
+ break
81
+ }
82
+ const lastNl = buf.lastIndexOf(0x0a, n - 1)
83
+ if (lastNl !== -1) {
84
+ let from = 0
85
+ for (;;) {
86
+ const nl = buf.indexOf(0x0a, from)
87
+ if (nl === -1 || nl > lastNl) break
88
+ lineNo++ // line `lineNo - 1` ends at this newline
89
+ if (lineNo === offset) windowStart = scanned + nl + 1
90
+ if (lineNo === offset + limit) {
91
+ windowEnd = scanned + nl
92
+ break
93
+ }
94
+ from = nl + 1
95
+ }
96
+ }
97
+ scanned += n
98
+ }
99
+
100
+ if (windowEnd === -1) {
101
+ // Either the file ended (the window is everything that is left) or the scan
102
+ // budget ran out before the window's last line was reached.
103
+ if (!eof) return null
104
+ windowEnd = scanned
105
+ }
106
+ if (windowStart === -1) return [] // `offset` is past the end of the file
107
+
108
+ const out = Buffer.allocUnsafe(windowEnd - windowStart)
109
+ let got = 0
110
+ while (got < out.length) {
111
+ const n = readSync(fd, out, got, out.length - got, windowStart + got)
112
+ if (n <= 0) break
113
+ got += n
114
+ }
115
+ return out.toString('utf-8', 0, got).split('\n')
116
+ }
117
+
9
118
  export function createReadTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
10
119
  return {
11
120
  name: 'Read',
@@ -40,59 +149,56 @@ export function createReadTool(credentialConfig?: CredentialMaskingConfig): Tool
40
149
  throw err
41
150
  }
42
151
 
43
- let content: string
44
152
  try {
45
- const stat = fstatSync(fd)
46
- if (stat.isDirectory()) {
153
+ if (fstatSync(fd).isDirectory()) {
47
154
  return { success: false, content: '', error: `Path is a directory: ${filePath}` }
48
155
  }
49
- // Prevent OOM on single-line files (e.g. 500MB JSON blob)
50
- const MAX_FILE_SIZE = 50_000_000 // 50 MB
51
- if (stat.size > MAX_FILE_SIZE) {
52
- return {
53
- success: false,
54
- content: '',
55
- error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
156
+
157
+ const offset = (params.offset as number) || 0
158
+ const limit = (params.limit as number) || DEFAULT_LIMIT
159
+
160
+ // ── Credential masking ──
161
+ // The rule matches on path alone, so this is decided before any read: a
162
+ // masked file needs its whole content (the mask is content-shaped), while
163
+ // every other file is served straight from the fd. Reading the file
164
+ // *first* is what used to make `offset`/`limit` useless on large files —
165
+ // the size check ran before the window was ever applied.
166
+ let result: string | null = null
167
+ if (credentialConfig) {
168
+ const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
169
+ await import('../../core/credential-masker')
170
+ const rule = matchCredentialFile(filePath, credentialConfig)
171
+ if (rule) {
172
+ const masked = maskContent(readFileSync(fd, 'utf-8'), rule)
173
+ // Full-file mask: return the sentinel immediately (offset/limit don't apply)
174
+ result =
175
+ masked === CREDENTIAL_SENTINEL
176
+ ? masked
177
+ : formatLines(windowFromString(masked, offset, limit), offset)
56
178
  }
57
179
  }
58
- content = readFileSync(fd, 'utf-8')
59
- } finally {
60
- closeSync(fd)
61
- }
62
180
 
63
- const offset = (params.offset as number) || 0
64
- const limit = (params.limit as number) || 2000
65
-
66
- // ── Read tracking: mark file as read for Write tool safety ──
67
- ctx.readFiles?.add(filePath)
68
-
69
- // ── Credential masking ──
70
- if (credentialConfig) {
71
- const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
72
- await import('../../core/credential-masker')
73
- const rule = matchCredentialFile(filePath, credentialConfig)
74
- if (rule) {
75
- const masked = maskContent(content, rule)
76
- // Full-file mask: return sentinel immediately (offset/limit don't apply)
77
- if (masked === CREDENTIAL_SENTINEL) {
78
- return { success: true, content: masked }
181
+ if (result === null) {
182
+ const lines = windowFromFd(fd, offset, limit)
183
+ if (lines === null) {
184
+ return {
185
+ success: false,
186
+ content: '',
187
+ error:
188
+ `File too large: scanned ${MAX_SCAN_BYTES / 1e6} MB without reaching the end of lines ` +
189
+ `${offset}–${offset + limit - 1}. Narrow the range (smaller offset or limit), or use a ` +
190
+ `shell tool to slice the file.`,
191
+ }
79
192
  }
80
- // Extract mode: apply offset/limit on masked content
81
- const maskedLines = masked.split('\n')
82
- const maskedSlice = maskedLines.slice(offset, offset + limit)
83
- const maskedResult = maskedSlice
84
- .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
85
- .join('\n')
86
- return { success: true, content: maskedResult }
193
+ result = formatLines(lines, offset)
87
194
  }
88
- }
89
195
 
90
- const lines = content.split('\n')
91
- const slice = lines.slice(offset, offset + limit)
92
- const result = slice
93
- .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
94
- .join('\n')
95
- return { success: true, content: result }
196
+ // ── Read tracking: mark file as read for Write tool safety ──
197
+ ctx.readFiles?.add(filePath)
198
+ return { success: true, content: result }
199
+ } finally {
200
+ closeSync(fd)
201
+ }
96
202
  },
97
203
  }
98
204
  }
@@ -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