@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
2
4
|
import { matchPath } from './credential-masker/matcher'
|
|
3
5
|
|
|
@@ -106,6 +108,9 @@ const PREFIX_COMMANDS = new Set([
|
|
|
106
108
|
'stdbuf',
|
|
107
109
|
])
|
|
108
110
|
|
|
111
|
+
/** `timeout` 的 duration 形态:`5` / `0.5` / `30s` / `2m` / `1h` / `1d`。 */
|
|
112
|
+
const TIMEOUT_DURATION_RE = /^\d+(\.\d+)?[smhd]?$/
|
|
113
|
+
|
|
109
114
|
/** Value-taking options of wrapper commands (consume the following token). */
|
|
110
115
|
const PREFIX_VALUE_OPTIONS = new Set([
|
|
111
116
|
'-u',
|
|
@@ -137,6 +142,34 @@ const PREFIX_VALUE_OPTIONS = new Set([
|
|
|
137
142
|
'--max-procs', // xargs
|
|
138
143
|
])
|
|
139
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Shell interpreters whose `-c` argument is itself a complete command line.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately NOT added to PREFIX_COMMANDS: that table shares
|
|
149
|
+
* PREFIX_VALUE_OPTIONS, where `-s` means `timeout --signal` — a shell's option
|
|
150
|
+
* grammar (`-c`, merged clusters like `-lc`, `-o <name>`) has nothing in common
|
|
151
|
+
* with a wrapper's value-taking options, and its payload is a *new command line*
|
|
152
|
+
* to re-parse rather than "the real command".
|
|
153
|
+
*
|
|
154
|
+
* This table is a security surface, not a compatibility surface: every name
|
|
155
|
+
* added is another matching path. Deliberately excluded — interpreters that
|
|
156
|
+
* evaluate *another language* (`node -e`, `python -c`, `awk`), remote execution
|
|
157
|
+
* (`ssh host '…'`, `docker exec`), script files (`bash x.sh`, `source x.sh`,
|
|
158
|
+
* whose payload is a file rather than a command line), and `find -exec`.
|
|
159
|
+
*/
|
|
160
|
+
const SHELL_COMMANDS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'ash'])
|
|
161
|
+
|
|
162
|
+
/** Shell short-option letters that consume the next token as a value (`-o pipefail`). */
|
|
163
|
+
const SHELL_VALUE_LETTERS = 'o'
|
|
164
|
+
|
|
165
|
+
/** Shell long options that consume the next token as a value. */
|
|
166
|
+
const SHELL_VALUE_OPTIONS = new Set(['--init-file', '--rcfile'])
|
|
167
|
+
|
|
168
|
+
/** How many `-c` / `$()` payload levels are re-parsed. Shared by both recursion
|
|
169
|
+
* paths — a single counter is what makes the bound hold; separate counters
|
|
170
|
+
* would let `bash -c 'bash -c "$(…)"'` alternate past it. */
|
|
171
|
+
const MAX_COMMAND_DEPTH = 5
|
|
172
|
+
|
|
140
173
|
/**
|
|
141
174
|
* Split a (possibly compound) shell command into simple-command segments, so a
|
|
142
175
|
* Bash(pattern) rule matches any segment rather than only the whole string
|
|
@@ -165,6 +198,76 @@ function stripQuotes(s: string): string {
|
|
|
165
198
|
return s
|
|
166
199
|
}
|
|
167
200
|
|
|
201
|
+
/** Shell 结构符号:分组与取反 —— 它们自身不是命令,紧跟其后的是。 */
|
|
202
|
+
const LEADING_SHELL_PUNCT = new Set(['(', '{', '!'])
|
|
203
|
+
|
|
204
|
+
/** Shell 关键字:其后才是真正的命令(`do rm -rf x` 执行的命令是 `rm`)。 */
|
|
205
|
+
const LEADING_SHELL_KEYWORDS = new Set([
|
|
206
|
+
'do',
|
|
207
|
+
'then',
|
|
208
|
+
'else',
|
|
209
|
+
'elif',
|
|
210
|
+
'if',
|
|
211
|
+
'while',
|
|
212
|
+
'until',
|
|
213
|
+
'for',
|
|
214
|
+
'case',
|
|
215
|
+
'time',
|
|
216
|
+
'coproc',
|
|
217
|
+
])
|
|
218
|
+
|
|
219
|
+
/** 一条前导赋值:`NAME=value`(name 是合法标识符,`=` 前无引号)。 */
|
|
220
|
+
const LEADING_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 剥掉一段 shell 片段**前导**的噪声 token,露出真正的基命令。
|
|
224
|
+
*
|
|
225
|
+
* 为什么需要它:`Bash(rm *)` / `Read(secret)` 这类规则要匹配的是**命令本身**,
|
|
226
|
+
* 而 shell 允许在命令前放赋值(`IFS=x rm -rf x`)、分组符号(`( rm -rf x )`)、
|
|
227
|
+
* 关键字(`for …; do rm -rf x; done`)。这些都不改变「执行了什么命令」,却足以
|
|
228
|
+
* 让匹配器看不到 `rm`,于是 deny 规则被一个空格级的改写绕过。
|
|
229
|
+
*
|
|
230
|
+
* 只剥前导、可反复剥(`FOO=1 ! rm …`)。剥多了一律是过匹配,而 deny 规则的过
|
|
231
|
+
* 匹配是安全方向。关键字自己的裸 flag 也一并剥掉(`time -p rm …` 里的 `-p`),
|
|
232
|
+
* 否则关键字被剥走后会剩下 `-p rm …`,仍然看不见 `rm`。
|
|
233
|
+
*
|
|
234
|
+
* 不剥尾随符号(`rm -rf x )` 里的 `)`):`wildcardMatch` 的 `*` 已经吃掉它。
|
|
235
|
+
*/
|
|
236
|
+
export function stripLeadingShellNoise(segment: string): string {
|
|
237
|
+
let tokens = segment.trim().split(/\s+/).filter(Boolean)
|
|
238
|
+
let stripped = false
|
|
239
|
+
let skipFlags = false
|
|
240
|
+
for (;;) {
|
|
241
|
+
const head = tokens[0]
|
|
242
|
+
if (!head) break
|
|
243
|
+
if (skipFlags && /^--?[A-Za-z]/.test(head)) {
|
|
244
|
+
tokens = tokens.slice(1)
|
|
245
|
+
stripped = true
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
skipFlags = false
|
|
249
|
+
const bare = head.replace(/^[({!]+/, '') // `(!` 这类连写
|
|
250
|
+
if (bare !== head) {
|
|
251
|
+
tokens = bare ? [bare, ...tokens.slice(1)] : tokens.slice(1)
|
|
252
|
+
stripped = true
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
if (LEADING_SHELL_PUNCT.has(head) || LEADING_ASSIGNMENT_RE.test(head)) {
|
|
256
|
+
tokens = tokens.slice(1)
|
|
257
|
+
stripped = true
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if (LEADING_SHELL_KEYWORDS.has(head)) {
|
|
261
|
+
tokens = tokens.slice(1)
|
|
262
|
+
stripped = true
|
|
263
|
+
skipFlags = true // `time -p rm …` —— 关键字自己的裸 flag 不是命令
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
return stripped ? tokens.join(' ') : segment
|
|
269
|
+
}
|
|
270
|
+
|
|
168
271
|
function uniq(items: string[]): string[] {
|
|
169
272
|
return [...new Set(items)]
|
|
170
273
|
}
|
|
@@ -175,6 +278,29 @@ export interface BashFileAccess {
|
|
|
175
278
|
write: string[]
|
|
176
279
|
}
|
|
177
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 展开候选路径里**已知**的变量:前导 `~`、`$HOME`/`${HOME}`。
|
|
283
|
+
*
|
|
284
|
+
* 为什么需要:规则里写的是绝对路径(`Read(/Users/me/.ssh/id_rsa)`),而用户敲的
|
|
285
|
+
* 是同一个文件的另一种拼法(`cat ~/.ssh/id_rsa`)—— 不展开即等于放行。展开后
|
|
286
|
+
* 两种拼法落到同一个字符串上,绝对路径形与路径通配形(如「.ssh 下任意文件」)
|
|
287
|
+
* 规则都能命中。
|
|
288
|
+
*
|
|
289
|
+
* **只展开 HOME**。`$FOO` 这类未知变量原样保留:展开它需要求值环境,静默展开成
|
|
290
|
+
* 空串会让 `/x/$FOO/y` 变成 `/x//y` —— 那是**新增**一个漏判方向,比不展开更坏。
|
|
291
|
+
* `$PWD` 同理需要 cwd,而 `matchBashRule` 的调用点拿不到 cwd,故未覆盖。这两条
|
|
292
|
+
* 都是本函数已知的边界,不是遗漏。
|
|
293
|
+
*
|
|
294
|
+
* `~` 只在**开头**展开(`a/~/b` 里的 `~` 是普通字符,shell 也不展开它)。
|
|
295
|
+
*/
|
|
296
|
+
export function expandKnownPathVars(p: string): string {
|
|
297
|
+
const home = homedir() // 每次取,不在模块加载时绑定(HOME 可能被测试隔离改写)
|
|
298
|
+
let out = p
|
|
299
|
+
if (out === '~') out = home
|
|
300
|
+
else if (out.startsWith('~/')) out = join(home, out.slice(2))
|
|
301
|
+
return out.replace(/\$\{?HOME\}?/g, home)
|
|
302
|
+
}
|
|
303
|
+
|
|
178
304
|
/**
|
|
179
305
|
* Extract the file paths a Bash command reads or writes, so Read()/Write()/
|
|
180
306
|
* Edit() deny rules also apply to Bash (not just the Read/Write/Edit tools).
|
|
@@ -203,7 +329,9 @@ export function extractBashFileAccess(command: string): BashFileAccess {
|
|
|
203
329
|
// recursing into `$(...)` / backtick substitutions.
|
|
204
330
|
scanReaderWriterCommands(command, read, write)
|
|
205
331
|
|
|
206
|
-
|
|
332
|
+
// 展开放在出口这一处,而不是每个 push 点 —— 重定向与读/写命令、以及它们的
|
|
333
|
+
// 递归内层都汇进这两个数组,一处展开即全覆盖。
|
|
334
|
+
return { read: uniq(read.map(expandKnownPathVars)), write: uniq(write.map(expandKnownPathVars)) }
|
|
207
335
|
}
|
|
208
336
|
|
|
209
337
|
/** Extract the inner commands of `$(...)` and backtick substitutions. */
|
|
@@ -216,25 +344,45 @@ function extractSubstitutions(command: string): string[] {
|
|
|
216
344
|
while ((m = dollarParen.exec(command)) !== null) inners.push(m[1]!)
|
|
217
345
|
const backtick = /`([^`]*)`/g
|
|
218
346
|
while ((m = backtick.exec(command)) !== null) inners.push(m[1]!)
|
|
347
|
+
// 进程替换 `<(cmd)` / `>(cmd)`:`cat <(cat secret)` 里读 secret 的是里层的
|
|
348
|
+
// `cat`,外层只是把它的 stdout 当成一个文件名。参数排除 `<>` 是因为
|
|
349
|
+
// `<(cat secret)` 的捕获若允许 `>`,遇到 `>(...)` 形态会被提前截断;非嵌套组
|
|
350
|
+
// 由调用方的递归处理。
|
|
351
|
+
const procSub = /[<>]\(([^()<>]*)\)/g
|
|
352
|
+
while ((m = procSub.exec(command)) !== null) inners.push(m[1]!)
|
|
219
353
|
return inners
|
|
220
354
|
}
|
|
221
355
|
|
|
222
356
|
/**
|
|
223
357
|
* Flatten a command into every matchable sub-command: its shell segments plus
|
|
224
|
-
* the commands nested inside `$(...)`/backtick substitutions
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
358
|
+
* the commands nested inside `$(...)`/backtick substitutions and shell `-c`
|
|
359
|
+
* payloads (recursively, bounded by MAX_COMMAND_DEPTH). So a `Bash(rm *)` deny
|
|
360
|
+
* rule also matches `REPORTTIME=$(rm -rf ~)` — zsh evaluates substitutions in
|
|
361
|
+
* REPORTTIME/REPORTMEMORY/DIRSTACKSIZE assignments immediately — and
|
|
362
|
+
* `bash -c 'rm -rf /'`. Over-matching is the safe direction for a deny rule.
|
|
228
363
|
*/
|
|
229
|
-
function flattenCommand(command: string): string[] {
|
|
364
|
+
function flattenCommand(command: string, depth = 0): string[] {
|
|
230
365
|
const out: string[] = []
|
|
231
366
|
for (const seg of splitShellSegments(command)) {
|
|
232
367
|
out.push(seg)
|
|
233
368
|
const stripped = stripPrefixCommand(seg)
|
|
234
369
|
if (stripped !== seg) out.push(stripped)
|
|
370
|
+
// 剥掉前导噪声后的形态也要参与匹配:`IFS=x rm -rf x` / `( rm -rf x )` /
|
|
371
|
+
// `for …; do rm -rf x; done` 执行的仍是 `rm`,规则必须看得见它。同一个函数
|
|
372
|
+
// 也被 scanReaderWriterCommands 用(Read/Write/Edit 桥接那条路径)—— 两条
|
|
373
|
+
// 路径共用一份归一化,只接一条就是只修一半。
|
|
374
|
+
const denoised = stripLeadingShellNoise(seg)
|
|
375
|
+
if (denoised !== seg) out.push(denoised)
|
|
235
376
|
for (const inner of extractSubstitutions(seg)) {
|
|
236
|
-
out.push(...flattenCommand(inner))
|
|
377
|
+
out.push(...flattenCommand(inner, depth + 1))
|
|
237
378
|
}
|
|
379
|
+
// A shell `-c` payload is a command line in its own right, so `Bash(rm *)`
|
|
380
|
+
// must also match `bash -c 'rm -rf /'`. Past the depth bound recursion stops
|
|
381
|
+
// outright rather than pushing the raw payload: the raw text
|
|
382
|
+
// (`bash -c 'rm -rf /'`) matches no `rm …` pattern anyway, and stopping
|
|
383
|
+
// keeps the boundary predictable.
|
|
384
|
+
const { payload } = effectiveCommand(seg.split(/\s+/).filter(Boolean))
|
|
385
|
+
if (payload && depth < MAX_COMMAND_DEPTH) out.push(...flattenCommand(payload, depth + 1))
|
|
238
386
|
}
|
|
239
387
|
return out
|
|
240
388
|
}
|
|
@@ -248,8 +396,13 @@ function flattenCommand(command: string): string[] {
|
|
|
248
396
|
* always treated as the command (never skipped) — which over-matches, the safe
|
|
249
397
|
* direction for a deny rule.
|
|
250
398
|
*/
|
|
251
|
-
function effectiveCommand(tokens: string[]): {
|
|
399
|
+
function effectiveCommand(tokens: string[]): {
|
|
400
|
+
base: string
|
|
401
|
+
args: string[]
|
|
402
|
+
payload: string | null
|
|
403
|
+
} {
|
|
252
404
|
let i = 0
|
|
405
|
+
let payload: string | null = null
|
|
253
406
|
while (i < tokens.length) {
|
|
254
407
|
const name = (tokens[i] || '').split('/').pop() || ''
|
|
255
408
|
if (!PREFIX_COMMANDS.has(name)) break
|
|
@@ -262,10 +415,69 @@ function effectiveCommand(tokens: string[]): { base: string; args: string[] } {
|
|
|
262
415
|
if (name === 'env') {
|
|
263
416
|
while (i < tokens.length && tokens[i]!.includes('=')) i++ // `VAR=value` assignments
|
|
264
417
|
}
|
|
265
|
-
|
|
418
|
+
// `timeout` 的位置参数是 duration,**不是**必然存在:`timeout 5 cmd` 有,
|
|
419
|
+
// `timeout --preserve-status cmd` 没有。无条件 `i++` 会把后者真正的命令
|
|
420
|
+
// (`cat`)当成 duration 吃掉,基命令退化成它的第一个参数。
|
|
421
|
+
if (name === 'timeout' && i < tokens.length && TIMEOUT_DURATION_RE.test(tokens[i]!)) i++
|
|
422
|
+
// `eval`'s remaining arguments are themselves a command line, and must be
|
|
423
|
+
// captured here rather than via the SHELL_COMMANDS branch below: `eval "cat
|
|
424
|
+
// secret"` tokenizes to ['eval', '"cat', 'secret"'], so the base degrades to
|
|
425
|
+
// `"cat` — a name no command set contains. The quotes land on *different*
|
|
426
|
+
// tokens, so de-quoting a single token cannot recover it either.
|
|
427
|
+
if (name === 'eval' && payload === null && i < tokens.length) {
|
|
428
|
+
payload = stripQuotes(tokens.slice(i).join(' '))
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (i >= tokens.length) return { base: '', args: [], payload }
|
|
432
|
+
const base = (tokens[i] || '').split('/').pop() || ''
|
|
433
|
+
if (payload === null && SHELL_COMMANDS.has(base)) payload = shellPayload(tokens, i)
|
|
434
|
+
return { base, args: tokens.slice(i + 1), payload }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The command string a shell runs via `-c` (`bash -c 'cat X'`). Only `-c` yields
|
|
439
|
+
* a nested command line — a script-file operand (`bash x.sh`) does not — so the
|
|
440
|
+
* scan stops at the first non-flag token, per getopt: `bash script.sh -c foo` is
|
|
441
|
+
* NOT a payload. Short options are walked as a cluster because shells accept
|
|
442
|
+
* `-lc` / `-xc` / `-euo pipefail`, which a flat option table (PREFIX_VALUE_OPTIONS)
|
|
443
|
+
* can never match.
|
|
444
|
+
*
|
|
445
|
+
* The payload is reassembled from every remaining token rather than taken from
|
|
446
|
+
* the one right after `-c`: it is a single shell word that routinely contains
|
|
447
|
+
* spaces (`'while read f; do cat "$f"; done'`), so whitespace tokenization has
|
|
448
|
+
* split it apart.
|
|
449
|
+
*/
|
|
450
|
+
function shellPayload(tokens: string[], start: number): string | null {
|
|
451
|
+
let i = start + 1
|
|
452
|
+
while (i < tokens.length) {
|
|
453
|
+
const t = tokens[i]!
|
|
454
|
+
if (!/^[-+]/.test(t) || t === '-' || t === '+') return null // first operand ends option parsing
|
|
455
|
+
if (t === '--') return null
|
|
456
|
+
if (t.startsWith('--')) {
|
|
457
|
+
i += SHELL_VALUE_OPTIONS.has(t) ? 2 : 1
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
const body = t.slice(1)
|
|
461
|
+
let consumed = 0
|
|
462
|
+
let inline: string | null = null
|
|
463
|
+
for (let k = 0; k < body.length; k++) {
|
|
464
|
+
const letter = body[k]!
|
|
465
|
+
if (letter === 'c') {
|
|
466
|
+
inline = body.slice(k + 1) // `-c'cat X'` attaches the payload to the flag
|
|
467
|
+
break
|
|
468
|
+
}
|
|
469
|
+
if (SHELL_VALUE_LETTERS.includes(letter)) {
|
|
470
|
+
if (k + 1 >= body.length) consumed = 1 // value is the next token
|
|
471
|
+
break
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (inline !== null) {
|
|
475
|
+
const tail = tokens.slice(i + 1).join(' ')
|
|
476
|
+
return stripQuotes([inline, tail].filter(Boolean).join(' ')) || null
|
|
477
|
+
}
|
|
478
|
+
i += 1 + consumed
|
|
266
479
|
}
|
|
267
|
-
|
|
268
|
-
return { base: (tokens[i] || '').split('/').pop() || '', args: tokens.slice(i + 1) }
|
|
480
|
+
return null
|
|
269
481
|
}
|
|
270
482
|
|
|
271
483
|
/** A command with any wrapper prefix commands stripped, so `sudo rm -rf /`
|
|
@@ -281,13 +493,22 @@ function stripPrefixCommand(command: string): string {
|
|
|
281
493
|
|
|
282
494
|
/**
|
|
283
495
|
* Detect reader/writer commands at the front of each shell segment and recurse
|
|
284
|
-
* into command substitutions
|
|
496
|
+
* into command substitutions and shell `-c` payloads, so both
|
|
497
|
+
* `echo $(cat .git-credentials)` and `bash -c 'cat .git-credentials'` are caught.
|
|
285
498
|
*/
|
|
286
|
-
function scanReaderWriterCommands(
|
|
499
|
+
function scanReaderWriterCommands(
|
|
500
|
+
command: string,
|
|
501
|
+
read: string[],
|
|
502
|
+
write: string[],
|
|
503
|
+
depth = 0,
|
|
504
|
+
): void {
|
|
287
505
|
for (const seg of splitShellSegments(command)) {
|
|
288
|
-
|
|
506
|
+
// 先剥前导噪声再 tokenize:`IFS=x cat secret` / `! cat secret` /
|
|
507
|
+
// `time -p cat secret` 读的是同一个文件,Read() 规则必须看得见 `cat`。
|
|
508
|
+
// 与 flattenCommand 共用 stripLeadingShellNoise —— 两条路径一份归一化。
|
|
509
|
+
const tokens = stripLeadingShellNoise(seg).split(/\s+/).filter(Boolean)
|
|
289
510
|
if (tokens.length > 0) {
|
|
290
|
-
const { base, args } = effectiveCommand(tokens)
|
|
511
|
+
const { base, args, payload } = effectiveCommand(tokens)
|
|
291
512
|
if (READER_COMMANDS.has(base)) {
|
|
292
513
|
// `sed -i` / `perl -i` read AND write their file args.
|
|
293
514
|
const inPlace = args.some((a) => a === '-i' || a.startsWith('--in-place'))
|
|
@@ -303,13 +524,25 @@ function scanReaderWriterCommands(command: string, read: string[], write: string
|
|
|
303
524
|
write.push(stripQuotes(arg))
|
|
304
525
|
}
|
|
305
526
|
}
|
|
527
|
+
// Re-parse a shell `-c` payload: `bash -c 'cat secret'` reads `secret`
|
|
528
|
+
// just as directly as `cat secret` does.
|
|
529
|
+
if (payload && depth < MAX_COMMAND_DEPTH) {
|
|
530
|
+
scanReaderWriterCommands(payload, read, write, depth + 1)
|
|
531
|
+
}
|
|
306
532
|
}
|
|
307
533
|
for (const inner of extractSubstitutions(seg)) {
|
|
308
|
-
scanReaderWriterCommands(inner, read, write)
|
|
534
|
+
scanReaderWriterCommands(inner, read, write, depth + 1)
|
|
309
535
|
}
|
|
310
536
|
}
|
|
311
537
|
}
|
|
312
538
|
|
|
539
|
+
/**
|
|
540
|
+
* `matchBashRule` 真正会按参数匹配工具名的集合。**与 matchBashRule 的分支一一
|
|
541
|
+
* 对应** —— 那里 `return false` 的工具,参数化规则在 validateRulePattern 里必须
|
|
542
|
+
* 被拒(否则规则永远不命中,是静默空防护)。改一边必须改另一边。
|
|
543
|
+
*/
|
|
544
|
+
const PARAMETERISED_TOOLS = new Set(['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob'])
|
|
545
|
+
|
|
313
546
|
// Match a tool(parameter) rule against an actual tool call.
|
|
314
547
|
//
|
|
315
548
|
// Pattern formats:
|
|
@@ -392,6 +625,10 @@ export function wildcardMatch(pattern: string, input: string): boolean {
|
|
|
392
625
|
* `Bash()`) is silently dead today — this returns a human-readable reason so
|
|
393
626
|
* the caller can report it as an invalid setting instead of ignoring it.
|
|
394
627
|
*
|
|
628
|
+
* 校验的是**结构 + 工具名**(后者见 PARAMETERISED_TOOLS)。**不**校验子模式本身
|
|
629
|
+
* 能否匹配任何东西 —— `Bash(zzz *)` 合法、装得上、只是不会命中,那是用户自己的
|
|
630
|
+
* 选择,不是配置错误。
|
|
631
|
+
*
|
|
395
632
|
* Returns null when the pattern is valid, or a reason string when malformed.
|
|
396
633
|
*/
|
|
397
634
|
export function validateRulePattern(pattern: string): string | null {
|
|
@@ -402,9 +639,16 @@ export function validateRulePattern(pattern: string): string | null {
|
|
|
402
639
|
// Empty parameter: `Bash()` or `Bash( )`
|
|
403
640
|
if (/\(\s*\)$/.test(pattern)) return 'empty parameter'
|
|
404
641
|
// Must be exactly `ToolName(param)` with nothing before or after.
|
|
405
|
-
|
|
642
|
+
const shape = pattern.match(/^(\w+)\((.+)\)$/)
|
|
643
|
+
if (!shape) {
|
|
406
644
|
return 'unexpected text after the closing parenthesis'
|
|
407
645
|
}
|
|
646
|
+
// 工具名必须落在 matchBashRule 真正会按参数匹配的那一集合里,否则这条规则是
|
|
647
|
+
// **静默空防护**:语法合法、装得上、永远不命中,而用户以为配了保护。
|
|
648
|
+
const tool = shape[1]!
|
|
649
|
+
if (!PARAMETERISED_TOOLS.has(tool)) {
|
|
650
|
+
return `parameterised rules are not supported for tool "${tool}" (it would never match)`
|
|
651
|
+
}
|
|
408
652
|
return null
|
|
409
653
|
}
|
|
410
654
|
|
package/src/core/permission.ts
CHANGED
|
@@ -215,14 +215,17 @@ export class PermissionSystem {
|
|
|
215
215
|
|
|
216
216
|
allow(rule: string): void {
|
|
217
217
|
this.allowRules.push(compileRule(rule, 'allow'))
|
|
218
|
+
this.invalidateCache()
|
|
218
219
|
}
|
|
219
220
|
|
|
220
221
|
deny(rule: string): void {
|
|
221
222
|
this.denyRules.push(compileRule(rule, 'deny'))
|
|
223
|
+
this.invalidateCache()
|
|
222
224
|
}
|
|
223
225
|
|
|
224
226
|
ask(rule: string): void {
|
|
225
227
|
this.askRules.push(compileRule(rule, 'ask'))
|
|
228
|
+
this.invalidateCache()
|
|
226
229
|
}
|
|
227
230
|
|
|
228
231
|
loadConfig(raw: {
|
package/src/core/session-log.ts
CHANGED
|
@@ -36,7 +36,8 @@ export function messageToEvents(msg: Message, at = 0): SessionEvent[] {
|
|
|
36
36
|
type: 'tool/result',
|
|
37
37
|
at,
|
|
38
38
|
id: r.tool_use_id,
|
|
39
|
-
|
|
39
|
+
// 读真值:旧消息无 is_error ⇒ 视为成功(向后兼容,无需迁移)
|
|
40
|
+
result: { success: !(r.is_error === true), content: r.content },
|
|
40
41
|
},
|
|
41
42
|
]
|
|
42
43
|
}
|
|
@@ -80,7 +81,15 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
|
|
|
80
81
|
const content = result.success ? result.content : result.error || result.content
|
|
81
82
|
out.push({
|
|
82
83
|
role: 'user',
|
|
83
|
-
content: [
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: 'tool_result',
|
|
87
|
+
tool_use_id: e.id,
|
|
88
|
+
content,
|
|
89
|
+
// 展平式的对称边:成功不写该键(与 messageToEvents 侧对称,保字节级互逆)
|
|
90
|
+
...(result.success ? {} : { is_error: true }),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
84
93
|
})
|
|
85
94
|
} else if (e.type === 'context/inject') {
|
|
86
95
|
out.push({ role: 'user', content: e.text })
|
|
@@ -154,7 +163,9 @@ export class SessionLog {
|
|
|
154
163
|
const trimmed = line.trim()
|
|
155
164
|
if (!trimmed) continue
|
|
156
165
|
try {
|
|
157
|
-
|
|
166
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
167
|
+
if (!isValidEvent(parsed)) continue
|
|
168
|
+
log.buf.push(parsed)
|
|
158
169
|
} catch {
|
|
159
170
|
// 跳过损坏行
|
|
160
171
|
}
|
|
@@ -164,6 +175,47 @@ export class SessionLog {
|
|
|
164
175
|
}
|
|
165
176
|
}
|
|
166
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
|
+
|
|
167
219
|
const SUMMARY_PREFIX = '[Earlier conversation summary]:'
|
|
168
220
|
|
|
169
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon 侧的引擎能力接线 —— 「两条渲染路径只接一条」的收口(ROADMAP T5)。
|
|
3
|
+
*
|
|
4
|
+
* 交互式 CLI 的装配内联在 `index.tsx`(约 300 行,含 TUI 专属件),而 daemon 这条
|
|
5
|
+
* 路径此前**一个 setter 都没接**。后果分三档,且都不报错:
|
|
6
|
+
*
|
|
7
|
+
* - `setSkills` 缺 ⇒ `createToolRegistry()` 无参调用照样把 `Skill` 工具挂进了注册表,
|
|
8
|
+
* 但工具上下文里没有 loader ⇒ 那个工具**每次调用都返回错误**(模型被广告了一个
|
|
9
|
+
* 永远失败的工具)。
|
|
10
|
+
* - `setRulesLoader` / `setHookEngine` / `setAgentRegistry` 缺 ⇒ 静默退化:
|
|
11
|
+
* `injectRules()` 永远早退、hooks 全不跑、自定义 agent 解析不到(`agent.ts` 里是
|
|
12
|
+
* `?.` 可选链,连警告都没有)。
|
|
13
|
+
* - `setLlm` 是**反例**:daemon 原本没有缝,`llmChat` 回退 registry、provider 回退
|
|
14
|
+
* 照常工作。所以顺序不可颠倒 —— 先修 `chatWithFallback` 的判据(见该处注释),
|
|
15
|
+
* 再接,否则「对等」会削掉 daemon 唯一还活着的回退。
|
|
16
|
+
*
|
|
17
|
+
* 这类缺口 lint / typecheck / 覆盖率 / 安全审计**全部看不见**(上一次同类事故是
|
|
18
|
+
* `rules-loader`,定义后潜伏数月,靠覆盖率实测才挖出来)。所以这里不只接线,还把
|
|
19
|
+
* daemon 该有的能力集中到**一个**装配点,让「再加一个能力忘了接 daemon」变成
|
|
20
|
+
* `test/integrity/daemon-capability-parity.test.ts` 能咬住的源码差异,而不是靠人记得。
|
|
21
|
+
*
|
|
22
|
+
* 不搬 `index.tsx` 的整套装配:ArtifactServer 是 TUI 画廊的 localhost 监听器、
|
|
23
|
+
* AgentViewManager 的唯一消费者是 TUI slash 命令。这里只接**有后果的四条**加
|
|
24
|
+
* `setLlm`;其余按后果分级进守卫的具名豁免表,逐条写理由。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { QueryEngine } from '../core/engine'
|
|
28
|
+
import type { ProviderRegistry } from '../providers/registry'
|
|
29
|
+
import { SkillsLoader } from '../skills/loader'
|
|
30
|
+
import { RulesLoader } from '../core/rules-loader'
|
|
31
|
+
import { HookEngine } from '../core/hooks'
|
|
32
|
+
import { loadHookConfigs } from '../core/hooks-config'
|
|
33
|
+
import { loadSettingsJson } from '../config/loader'
|
|
34
|
+
import { AgentRegistry } from '../agent/agent-registry'
|
|
35
|
+
|
|
36
|
+
export interface DaemonEngineCapabilities {
|
|
37
|
+
/** 会话工作目录 —— 三者的派生源(外部 skill 路径 / hooks / agents 都从它算)。 */
|
|
38
|
+
cwd: string
|
|
39
|
+
/** daemon 共享的 provider registry,注入为 LLM 缝。 */
|
|
40
|
+
registry: ProviderRegistry
|
|
41
|
+
/** `config.skills.paths`(外部 skill 目录)。省略即只加载内置与用户 skill。 */
|
|
42
|
+
skillsPaths?: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 按 cwd 记忆化。
|
|
47
|
+
*
|
|
48
|
+
* daemon 是一个长命进程服务多个会话、cwd 各不相同,而 skills / hooks / agents 三者
|
|
49
|
+
* 都从 cwd 派生(`config.skills.paths` / `<cwd>/.mipham/settings.json` /
|
|
50
|
+
* `<cwd>/.mipham/agents`)。每次 `getOrCreateEngine` 都重建会重复读盘与解析。
|
|
51
|
+
*
|
|
52
|
+
* 缓存是**有界**的:cwd 必须先过 `isCwdAllowed`(在 daemon 根之内,或在用户信任
|
|
53
|
+
* 列表里 —— 见 `workspace-guard.ts`),不是调用方随便给的路径。
|
|
54
|
+
*
|
|
55
|
+
* 代价已认下:表活到进程结束,所以改 `~/.mipham/settings.json` 或项目 agents 要
|
|
56
|
+
* **重启 daemon** 才生效。CLI 是一次性进程,没这个问题 —— 不做 per-session rebuild,
|
|
57
|
+
* 真要失效化另立条目。
|
|
58
|
+
*/
|
|
59
|
+
const skillsCache = new Map<string, SkillsLoader>()
|
|
60
|
+
const hookCache = new Map<string, HookEngine>()
|
|
61
|
+
const agentCache = new Map<string, AgentRegistry>()
|
|
62
|
+
|
|
63
|
+
function skillsFor(cwd: string, paths?: string[]): SkillsLoader {
|
|
64
|
+
const cached = skillsCache.get(cwd)
|
|
65
|
+
if (cached) return cached
|
|
66
|
+
const loader = new SkillsLoader()
|
|
67
|
+
// 内置 skill 从包内解析(`import.meta.dirname`),与 cwd 无关;用户 skill 在
|
|
68
|
+
// `~/.mipham/skills`。三者里只有外部路径随 cwd 变。
|
|
69
|
+
loader.loadBuiltinFromPackage()
|
|
70
|
+
loader.loadUserSkills()
|
|
71
|
+
if (paths && paths.length > 0) loader.loadExternal(paths)
|
|
72
|
+
skillsCache.set(cwd, loader)
|
|
73
|
+
return loader
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* skill 自带的 hooks 先注册,`settings.json` 的随后 —— 与 `index.tsx:585-598` 同序。
|
|
78
|
+
*
|
|
79
|
+
* 内置 skill 当前**一个都不声明 hooks**(实测 grep 为 0),所以这条只在用户自装
|
|
80
|
+
* skill 上生效。留着是为了两入口的行为集合真的相等:少了它,装了带 hooks 的 skill
|
|
81
|
+
* 的用户在 daemon 里会静默少跑一半 hooks —— 正是本项要消灭的那类缺口。
|
|
82
|
+
*
|
|
83
|
+
* 注意 `settings.json` 是**仓库可控**的文件(`<cwd>/.mipham/settings.json`),
|
|
84
|
+
* 其 hooks 会 spawn shell。这与 CLI 同构,但 daemon 的会话可被远程渠道调用者驱动,
|
|
85
|
+
* 属本次已认下的后果,见 ROADMAP T5 落地结果。
|
|
86
|
+
*/
|
|
87
|
+
function hooksFor(cwd: string, skills: SkillsLoader): HookEngine {
|
|
88
|
+
const cached = hookCache.get(cwd)
|
|
89
|
+
if (cached) return cached
|
|
90
|
+
const engine = new HookEngine()
|
|
91
|
+
for (const skill of skills.list()) {
|
|
92
|
+
for (const hook of skill.hooks ?? []) engine.register(hook)
|
|
93
|
+
}
|
|
94
|
+
for (const def of loadHookConfigs(loadSettingsJson(cwd).hooks)) engine.register(def)
|
|
95
|
+
hookCache.set(cwd, engine)
|
|
96
|
+
return engine
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function agentsFor(cwd: string): AgentRegistry {
|
|
100
|
+
const cached = agentCache.get(cwd)
|
|
101
|
+
if (cached) return cached
|
|
102
|
+
const registry = new AgentRegistry()
|
|
103
|
+
registry.loadUserAgents()
|
|
104
|
+
registry.loadProjectAgents(cwd)
|
|
105
|
+
agentCache.set(cwd, registry)
|
|
106
|
+
return registry
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 把一个 daemon 引擎接成「和交互式 CLI 同等能干活」的样子。
|
|
111
|
+
*
|
|
112
|
+
* 调用点在 `server.ts` 的 `getOrCreateEngine`,紧跟 `setSessionId` —— 引擎建成之后、
|
|
113
|
+
* 进 `engineCache` 之前,保证任何取到引擎的路径都已经接过线。
|
|
114
|
+
*/
|
|
115
|
+
export function wireDaemonEngine(engine: QueryEngine, opts: DaemonEngineCapabilities): void {
|
|
116
|
+
const skills = skillsFor(opts.cwd, opts.skillsPaths)
|
|
117
|
+
engine.setSkills(skills)
|
|
118
|
+
|
|
119
|
+
// **每会话新建、不记忆化**:`setRulesLoader` 会顺手 `loader.load()`
|
|
120
|
+
// (`engine.ts:352-355`),而 `load()` 是同步、幂等、先清空再读几个小文件
|
|
121
|
+
// (`rules-loader.ts:42-61`)。共享一只的话,一个长命 daemon 会永远看不见会话
|
|
122
|
+
// 期间新增的规则;不共享的代价只是每次建引擎多读一次目录。
|
|
123
|
+
engine.setRulesLoader(new RulesLoader(opts.cwd))
|
|
124
|
+
|
|
125
|
+
engine.setHookEngine(hooksFor(opts.cwd, skills))
|
|
126
|
+
engine.setAgentRegistry(agentsFor(opts.cwd))
|
|
127
|
+
|
|
128
|
+
// 语义等价于不接(`llmChat` 本就是 `this.llm ?? this.registry`),接上是为了让
|
|
129
|
+
// 两个入口的能力集合真的相等;D2 靠它证明「先修语义再对等」这条顺序约束。
|
|
130
|
+
engine.setLlm(opts.registry)
|
|
131
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -276,7 +276,10 @@ export async function stopDaemon(force: boolean = false): Promise<void> {
|
|
|
276
276
|
|
|
277
277
|
// Stop HTTP server (disconnects WebSocket clients)
|
|
278
278
|
if (activeServer) {
|
|
279
|
-
|
|
279
|
+
// Awaited: the PID/port cleanup below is documented to run regardless of
|
|
280
|
+
// failure, so a rejection must not skip it — but the stop must finish before
|
|
281
|
+
// this function reports the daemon as stopped.
|
|
282
|
+
await activeServer.stop().catch(() => {})
|
|
280
283
|
activeServer = null
|
|
281
284
|
}
|
|
282
285
|
|