@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.
- package/README.md +1 -1
- package/bin/mipham.ts +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +14 -9
- package/src/config/loader.ts +202 -63
- package/src/config/preferences.ts +5 -2
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/engine.ts +7 -2
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +157 -6
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +49 -2
- package/src/core/session-store.ts +11 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/daemon/session-worker.ts +15 -0
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +47 -19
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +30 -8
- package/src/providers/anthropic.ts +74 -13
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +25 -2
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +17 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +146 -24
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +83 -3
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +70 -16
- package/src/tools/file/read.ts +151 -45
- package/src/tools/index.ts +12 -4
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +6 -2
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +187 -41
- package/src/workflow/primitives/agent.ts +4 -0
- package/src/artifacts/versioning.ts +0 -127
package/src/core/permission.ts
CHANGED
|
@@ -7,7 +7,13 @@ import type {
|
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
9
9
|
import { matchBashRule, compileRule } from './permission-rules'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
loadPermissionConfig,
|
|
12
|
+
nextMode,
|
|
13
|
+
clampMode,
|
|
14
|
+
normalizeRestrictions,
|
|
15
|
+
MODE_CYCLE,
|
|
16
|
+
} from './permission-config'
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Check if a Bash command is a "verification-only" command that should be
|
|
@@ -78,6 +84,8 @@ export class PermissionSystem {
|
|
|
78
84
|
private allowRules: PermissionRuleEntry[] = []
|
|
79
85
|
private denyRules: PermissionRuleEntry[] = []
|
|
80
86
|
private askRules: PermissionRuleEntry[] = []
|
|
87
|
+
/** Malformed `permissionRestrictions` entries from the last set/load — see below. */
|
|
88
|
+
private restrictionWarnings: string[] = []
|
|
81
89
|
/** Legacy exact-name rules for backward compat (set via setRule with 'auto' level). */
|
|
82
90
|
private legacyRules = new Map<string, PermissionLevel>()
|
|
83
91
|
/** Legacy default level from constructor when passed non-mode values like 'ask' or 'bypass'. */
|
|
@@ -130,9 +138,17 @@ export class PermissionSystem {
|
|
|
130
138
|
|
|
131
139
|
// ── Restrictions (P0: org-level policy gap) ──
|
|
132
140
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Apply org-level permission restrictions. Overwrites any previous restrictions.
|
|
143
|
+
*
|
|
144
|
+
* The value is **validated and normalized** first: a config typo used to leave the
|
|
145
|
+
* whole policy silently inert (fail-open). Anything unrecognizable is now reported
|
|
146
|
+
* via `getInvalidRestrictions()` and pins the mode to the strictest (`P1`).
|
|
147
|
+
*/
|
|
148
|
+
setRestrictions(raw: PermissionRestrictions | undefined): void {
|
|
149
|
+
const { restrictions, invalid } = normalizeRestrictions(raw)
|
|
135
150
|
this.restrictions = restrictions
|
|
151
|
+
this.restrictionWarnings = invalid
|
|
136
152
|
// Re-clamp current mode against new restrictions
|
|
137
153
|
if (restrictions) {
|
|
138
154
|
this.mode = clampMode(this.mode, restrictions)
|
|
@@ -140,6 +156,15 @@ export class PermissionSystem {
|
|
|
140
156
|
this.invalidateCache()
|
|
141
157
|
}
|
|
142
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Malformed `permissionRestrictions` entries, one message each — the sibling of
|
|
161
|
+
* `getInvalidRules()`. Callers are expected to surface these to stderr; a silent
|
|
162
|
+
* return here means a policy the operator believes is enforced isn't.
|
|
163
|
+
*/
|
|
164
|
+
getInvalidRestrictions(): string[] {
|
|
165
|
+
return this.restrictionWarnings
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
getRestrictions(): PermissionRestrictions | undefined {
|
|
144
169
|
return this.restrictions
|
|
145
170
|
}
|
|
@@ -242,7 +267,13 @@ export class PermissionSystem {
|
|
|
242
267
|
restrictions: PermissionRestrictions
|
|
243
268
|
}>,
|
|
244
269
|
)
|
|
245
|
-
|
|
270
|
+
// Same validation as setRestrictions — a config typo must not silently drop the cap
|
|
271
|
+
if (config.restrictions) {
|
|
272
|
+
const { restrictions, invalid } = normalizeRestrictions(config.restrictions)
|
|
273
|
+
this.restrictions = restrictions
|
|
274
|
+
this.restrictionWarnings = invalid
|
|
275
|
+
}
|
|
276
|
+
this.mode = clampMode(config.mode, this.restrictions)
|
|
246
277
|
|
|
247
278
|
this.allowRules = []
|
|
248
279
|
this.denyRules = []
|
|
@@ -255,10 +286,6 @@ export class PermissionSystem {
|
|
|
255
286
|
this.denyRules.push(compileRule(rule, 'deny'))
|
|
256
287
|
}
|
|
257
288
|
|
|
258
|
-
if (config.restrictions) {
|
|
259
|
-
this.restrictions = config.restrictions
|
|
260
|
-
}
|
|
261
|
-
|
|
262
289
|
this.invalidateCache()
|
|
263
290
|
}
|
|
264
291
|
|
|
@@ -310,10 +337,10 @@ export class PermissionSystem {
|
|
|
310
337
|
}
|
|
311
338
|
}
|
|
312
339
|
|
|
313
|
-
// 3. Check allow rules
|
|
340
|
+
// 3. Check allow rules — but an org ceiling still applies (see allowRuleDecision)
|
|
314
341
|
for (const rule of this.allowRules) {
|
|
315
342
|
if (this.ruleMatches(rule, tool, input)) {
|
|
316
|
-
const result
|
|
343
|
+
const result = this.allowRuleDecision(tool, input)
|
|
317
344
|
this.checkCache.set(cacheKey, result)
|
|
318
345
|
return result
|
|
319
346
|
}
|
|
@@ -399,19 +426,60 @@ export class PermissionSystem {
|
|
|
399
426
|
tool: ToolDefinition,
|
|
400
427
|
input: Record<string, unknown>,
|
|
401
428
|
): boolean {
|
|
402
|
-
// Try Bash-style matching first
|
|
429
|
+
// Try Bash-style matching first.
|
|
430
|
+
//
|
|
431
|
+
// The compound-command rule differs by direction, and the rule's own
|
|
432
|
+
// `level` is what decides it: a **deny/ask** rule matches if *any* part of
|
|
433
|
+
// a compound command matches (wide on purpose), while an **allow** rule
|
|
434
|
+
// matches only if *every* part does. Without that, `Bash(git:*)` grants
|
|
435
|
+
// `git status && rm -rf ./src` outright — see matchBashRule's `segmentMode`.
|
|
403
436
|
if (rule.pattern.includes('(')) {
|
|
404
|
-
return matchBashRule(rule.pattern, tool.name, input)
|
|
437
|
+
return matchBashRule(rule.pattern, tool.name, input, rule.level === 'allow' ? 'all' : 'any')
|
|
405
438
|
}
|
|
406
439
|
// Simple tool name match
|
|
407
440
|
return rule.pattern === tool.name || rule.compiled.test(tool.name)
|
|
408
441
|
}
|
|
409
442
|
|
|
443
|
+
/**
|
|
444
|
+
* An allow rule matched — what does it actually grant?
|
|
445
|
+
*
|
|
446
|
+
* Without an org ceiling it grants `'bypass'` (unchanged behavior). With
|
|
447
|
+
* `maxAllowedMode` set, the rule may only grant what **the ceiling's own
|
|
448
|
+
* baseline** would grant: the ceiling is an upper bound on permissiveness, and
|
|
449
|
+
* a rule is a *source of permission* — letting it jump over the ceiling is the
|
|
450
|
+
* same defect one layer in. So `allow: ['Bash']` under a ceiling of
|
|
451
|
+
* `acceptEdits` still permits verification-only Bash, while `git push` falls
|
|
452
|
+
* back to approval.
|
|
453
|
+
*
|
|
454
|
+
* Decided here, at check time, rather than inside `allow()`: `setRestrictions`
|
|
455
|
+
* may land **after** the rules are registered (`loadConfig`, sub-agents handed
|
|
456
|
+
* the same restrictions), and a rule registered before the ceiling would
|
|
457
|
+
* otherwise keep its old meaning.
|
|
458
|
+
*
|
|
459
|
+
* Only `maxAllowedMode` gates this. `forbiddenModes` is about *which mode you
|
|
460
|
+
* may sit in*, not about what a rule may grant, so a `forbiddenModes`-only
|
|
461
|
+
* config behaves exactly as before.
|
|
462
|
+
*
|
|
463
|
+
* The `'mode-baseline'` sentinel (mode `default`) resolves to `tool.permission`
|
|
464
|
+
* and stops there — deliberately not continuing to the legacy fallback of
|
|
465
|
+
* `check()` step 7. That fallback can only ever be *wider* than `'ask'`, and a
|
|
466
|
+
* ceiling must not hand out more than the un-restricted chain would.
|
|
467
|
+
*/
|
|
468
|
+
private allowRuleDecision(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
|
|
469
|
+
const cap = this.restrictions?.maxAllowedMode
|
|
470
|
+
if (!cap) return 'bypass'
|
|
471
|
+
|
|
472
|
+
const baseline = this.modeBaseline(tool, input, cap)
|
|
473
|
+
const level = baseline === 'mode-baseline' ? (tool.permission ?? 'ask') : baseline
|
|
474
|
+
return level === 'ask' ? 'ask' : 'bypass'
|
|
475
|
+
}
|
|
476
|
+
|
|
410
477
|
private modeBaseline(
|
|
411
478
|
tool: ToolDefinition,
|
|
412
479
|
input?: Record<string, unknown>,
|
|
480
|
+
mode: PermissionMode = this.mode,
|
|
413
481
|
): PermissionLevel | 'mode-baseline' {
|
|
414
|
-
switch (
|
|
482
|
+
switch (mode) {
|
|
415
483
|
case 'default':
|
|
416
484
|
// Delegate to tool.permission (backward compat)
|
|
417
485
|
return 'mode-baseline'
|
package/src/core/rules-loader.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
21
21
|
import { join } from 'node:path'
|
|
22
22
|
import { globToRegexSource } from './credential-masker/matcher'
|
|
23
|
+
import { findWorktreeMarker } from './paths.ts'
|
|
23
24
|
|
|
24
25
|
interface RuleFile {
|
|
25
26
|
name: string
|
|
@@ -31,9 +32,27 @@ interface RuleFile {
|
|
|
31
32
|
export class RulesLoader {
|
|
32
33
|
private rules: RuleFile[] = []
|
|
33
34
|
private rulesDir: string
|
|
35
|
+
/**
|
|
36
|
+
* Rules directory of the **project the cwd belongs to**, when cwd sits inside
|
|
37
|
+
* one of our worktrees; `null` otherwise.
|
|
38
|
+
*
|
|
39
|
+
* `.mipham/` is gitignored, so a worktree checkout never contains
|
|
40
|
+
* `.mipham/rules` — `git worktree add .mipham/worktrees/w1` produces a tree
|
|
41
|
+
* with no `.mipham/` at all (measured). Reading only `cwd` therefore made
|
|
42
|
+
* every project rule invisible inside a worktree session, **silently**: zero
|
|
43
|
+
* rules, no warning, empty context block.
|
|
44
|
+
*
|
|
45
|
+
* Detection is marker-based (`findWorktreeMarker`, the same one the git/bash
|
|
46
|
+
* tools use) — a worktree created outside `.mipham/worktrees/` and
|
|
47
|
+
* `.claude/worktrees/` is not covered.
|
|
48
|
+
*/
|
|
49
|
+
private projectRulesDir: string | null
|
|
34
50
|
|
|
35
51
|
constructor(cwd: string) {
|
|
36
52
|
this.rulesDir = join(cwd, '.mipham', 'rules')
|
|
53
|
+
const marker = findWorktreeMarker(cwd)
|
|
54
|
+
const projectRulesDir = marker ? join(marker.root, '.mipham', 'rules') : null
|
|
55
|
+
this.projectRulesDir = projectRulesDir === this.rulesDir ? null : projectRulesDir
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
/**
|
|
@@ -41,16 +60,27 @@ export class RulesLoader {
|
|
|
41
60
|
*/
|
|
42
61
|
load(): void {
|
|
43
62
|
this.rules = []
|
|
44
|
-
|
|
63
|
+
this.readDir(this.rulesDir)
|
|
64
|
+
if (this.projectRulesDir) this.readDir(this.projectRulesDir)
|
|
65
|
+
}
|
|
45
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Read every `.md` in `dir` into `this.rules`. A name already loaded wins —
|
|
69
|
+
* "nearest first": a rule the worktree defines overrides the project copy.
|
|
70
|
+
*/
|
|
71
|
+
private readDir(dir: string): void {
|
|
72
|
+
if (!existsSync(dir)) return
|
|
46
73
|
try {
|
|
47
|
-
const
|
|
74
|
+
const seen = new Set(this.rules.map((r) => r.name))
|
|
75
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.md'))
|
|
48
76
|
for (const file of files) {
|
|
49
|
-
const
|
|
77
|
+
const name = file.replace(/\.md$/, '')
|
|
78
|
+
if (seen.has(name)) continue
|
|
50
79
|
try {
|
|
51
|
-
const raw = readFileSync(
|
|
80
|
+
const raw = readFileSync(join(dir, file), 'utf-8')
|
|
52
81
|
const { paths, description, content } = this.parseRule(raw, file)
|
|
53
|
-
this.rules.push({ name
|
|
82
|
+
this.rules.push({ name, paths, description, content })
|
|
83
|
+
seen.add(name)
|
|
54
84
|
} catch {
|
|
55
85
|
// Skip unparseable files
|
|
56
86
|
}
|
package/src/core/session-log.ts
CHANGED
|
@@ -78,7 +78,11 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
|
|
|
78
78
|
// 兼容旧 JSONL(存 content:string);新格式存 result:ToolResult(含 success/error)
|
|
79
79
|
const eo = e as unknown as { id: string; result?: ToolResult; content?: string }
|
|
80
80
|
const result: ToolResult = eo.result ?? { success: true, content: eo.content ?? '' }
|
|
81
|
-
|
|
81
|
+
// `?? ''`:成功但没记下 content 的事件(`JSON.stringify` 会把 `undefined` 的键整个
|
|
82
|
+
// 抹掉,所以它在盘上长这样:`{"success":true}`)投影出来必须是**字符串**。
|
|
83
|
+
// 投影负责形状、provider 负责出网合法(`tool_result.content` 是 `string`);
|
|
84
|
+
// 这里留 `undefined` 会让 `JSON.stringify` 同样抹掉该键,把问题送到线上。
|
|
85
|
+
const content = (result.success ? result.content : result.error || result.content) ?? ''
|
|
82
86
|
out.push({
|
|
83
87
|
role: 'user',
|
|
84
88
|
content: [
|
|
@@ -163,7 +167,9 @@ export class SessionLog {
|
|
|
163
167
|
const trimmed = line.trim()
|
|
164
168
|
if (!trimmed) continue
|
|
165
169
|
try {
|
|
166
|
-
|
|
170
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
171
|
+
if (!isValidEvent(parsed)) continue
|
|
172
|
+
log.buf.push(parsed)
|
|
167
173
|
} catch {
|
|
168
174
|
// 跳过损坏行
|
|
169
175
|
}
|
|
@@ -173,6 +179,47 @@ export class SessionLog {
|
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
/**
|
|
183
|
+
* 事件结构校验 —— 磁盘→内存的**唯一**入口(`open()`)用它挡掉坏行。
|
|
184
|
+
*
|
|
185
|
+
* 只校验 `deriveMessages` 会解引用的字段:这不是 schema 校验,是「投影不许崩」的
|
|
186
|
+
* 最低门槛。**合法 JSON 不等于合法事件** —— `null`、`{"type":"user/message"}`(无
|
|
187
|
+
* message)、`{"type":"compaction/rewrite"}`(无 messages)都过得了 `JSON.parse`,
|
|
188
|
+
* 却让投影抛 TypeError(rewrite 那条更狠:`out` 直接变 `undefined`,下一条就炸),
|
|
189
|
+
* 而 `/resume` 那条链上没有 try 兜它。坏行来自手写/拼接/半截重排,不是本进程写的。
|
|
190
|
+
*
|
|
191
|
+
* 不校验 `session/start`、`assistant/chunk`、`checker/decision`:投影对它们无分支;
|
|
192
|
+
* 未知类型一律放行(前向兼容 —— 认不出来不等于要销毁它)。
|
|
193
|
+
*/
|
|
194
|
+
function isValidEvent(e: unknown): e is SessionEvent {
|
|
195
|
+
if (!e || typeof e !== 'object') return false
|
|
196
|
+
const ev = e as Record<string, unknown>
|
|
197
|
+
if (typeof ev.type !== 'string') return false
|
|
198
|
+
switch (ev.type) {
|
|
199
|
+
case 'user/message':
|
|
200
|
+
case 'assistant/message':
|
|
201
|
+
return !!ev.message && typeof ev.message === 'object'
|
|
202
|
+
case 'tool/call':
|
|
203
|
+
return (
|
|
204
|
+
typeof ev.id === 'string' &&
|
|
205
|
+
typeof ev.name === 'string' &&
|
|
206
|
+
!!ev.input &&
|
|
207
|
+
typeof ev.input === 'object'
|
|
208
|
+
)
|
|
209
|
+
case 'tool/result':
|
|
210
|
+
// 只要求 id:`result`(新格式)与 `content`(旧 JSONL)都可缺省,派生侧已兜底
|
|
211
|
+
return typeof ev.id === 'string'
|
|
212
|
+
case 'context/inject':
|
|
213
|
+
return typeof ev.text === 'string'
|
|
214
|
+
case 'compaction/summary':
|
|
215
|
+
return typeof ev.summary === 'string'
|
|
216
|
+
case 'compaction/rewrite':
|
|
217
|
+
return Array.isArray(ev.messages)
|
|
218
|
+
default:
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
176
223
|
const SUMMARY_PREFIX = '[Earlier conversation summary]:'
|
|
177
224
|
|
|
178
225
|
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
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import { join, dirname, resolve } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
4
5
|
|
|
5
6
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
6
7
|
const TRUST_STORE_PATH = join(MIPHAM_HOME, 'trusted-workspaces.json')
|
|
@@ -67,8 +68,16 @@ export class WorkspaceTrust {
|
|
|
67
68
|
}
|
|
68
69
|
const raw = readFileSync(TRUST_STORE_PATH, 'utf-8')
|
|
69
70
|
const parsed = JSON.parse(raw) as TrustedWorkspaces
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
// The version check alone guarded a *field* while leaving the shape open:
|
|
72
|
+
// `{ version: 1 }` passes it and then `for (const trusted of
|
|
73
|
+
// this.store.directories)` throws on `undefined`. Entries must be strings
|
|
74
|
+
// too — `isTrusted` calls `.toLowerCase()` on each one.
|
|
75
|
+
if (
|
|
76
|
+
parsed.version !== 1 ||
|
|
77
|
+
!Array.isArray(parsed.directories) ||
|
|
78
|
+
!parsed.directories.every((d) => typeof d === 'string')
|
|
79
|
+
) {
|
|
80
|
+
// Unknown version or malformed shape — reset
|
|
72
81
|
return { version: 1, directories: [], updatedAt: new Date().toISOString() }
|
|
73
82
|
}
|
|
74
83
|
return parsed
|
|
@@ -84,7 +93,14 @@ export class WorkspaceTrust {
|
|
|
84
93
|
mkdirSync(MIPHAM_HOME, { recursive: true })
|
|
85
94
|
}
|
|
86
95
|
this.store.updatedAt = new Date().toISOString()
|
|
87
|
-
|
|
96
|
+
// Atomic: this store's own reader swallows a parse failure into a *reset
|
|
97
|
+
// store* (see `load`'s catch), so a write interrupted midway does not just
|
|
98
|
+
// lose the record — it silently un-trusts every directory the user had
|
|
99
|
+
// approved. Fail-closed, but the user never learns why they are being
|
|
100
|
+
// asked again.
|
|
101
|
+
atomicWriteFileSync(TRUST_STORE_PATH, JSON.stringify(this.store, null, 2) + '\n', {
|
|
102
|
+
mode: 0o600,
|
|
103
|
+
})
|
|
88
104
|
} catch {
|
|
89
105
|
// Best-effort: don't crash if we can't save
|
|
90
106
|
}
|
|
@@ -165,3 +181,25 @@ export function getWorkspaceTrust(): WorkspaceTrust {
|
|
|
165
181
|
export function resetWorkspaceTrust(): void {
|
|
166
182
|
_instance = null
|
|
167
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Say out loud that repository-controlled config was skipped because this
|
|
187
|
+
* workspace is not trusted.
|
|
188
|
+
*
|
|
189
|
+
* Call this only when hooks were *actually* withheld — `SettingsJson` reports
|
|
190
|
+
* that (`projectHooksSkipped`), so this cannot announce a skip that never
|
|
191
|
+
* happened.
|
|
192
|
+
*
|
|
193
|
+
* Deliberately not silent. When there is no TTY the trust prompt cannot be
|
|
194
|
+
* asked, so the answer is "no" and the hooks stay out — but a gate that fails
|
|
195
|
+
* without saying so is indistinguishable from one that passed, and the user is
|
|
196
|
+
* left wondering why their hooks do nothing. Written to stderr so it cannot
|
|
197
|
+
* corrupt stdout rendering.
|
|
198
|
+
*/
|
|
199
|
+
export function warnProjectHooksSkipped(cwd: string): void {
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`⚠️ Workspace not trusted: skipped hooks from ${join(cwd, '.mipham', 'settings.json')}\n` +
|
|
202
|
+
` (hooks run commands — repository-controlled). Trust this directory in an interactive\n` +
|
|
203
|
+
` session to enable them.\n`,
|
|
204
|
+
)
|
|
205
|
+
}
|
package/src/daemon/auth.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
// apps/cli/src/daemon/auth.ts
|
|
2
|
-
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
4
4
|
import { dirname } from 'node:path'
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* bun-types@1.3.14 lacks constantTimeCompare in type definitions,
|
|
8
|
-
* though the method exists at Bun 1.2+ runtime.
|
|
9
|
-
*/
|
|
10
|
-
interface PasswordWithCompare {
|
|
11
|
-
constantTimeCompare(a: Buffer, b: Buffer): boolean
|
|
12
|
-
}
|
|
13
|
-
|
|
14
6
|
/**
|
|
15
7
|
* Generate a 64-character hex token using cryptographically secure random bytes.
|
|
16
8
|
*/
|
|
@@ -35,14 +27,23 @@ export function loadOrCreateToken(tokenPath: string): string {
|
|
|
35
27
|
|
|
36
28
|
/**
|
|
37
29
|
* Verify a provided token against the expected token.
|
|
38
|
-
* Uses
|
|
30
|
+
* Uses a constant-time comparison to prevent timing attacks.
|
|
31
|
+
*
|
|
32
|
+
* `Bun.password` has no `constantTimeCompare` — on bun 1.3.14 it is
|
|
33
|
+
* `['hash','hashSync','verify','verifySync']`. Calling it threw a TypeError, so
|
|
34
|
+
* every authenticated remote request got a 500 instead of the intended 200/403.
|
|
35
|
+
* Node's `timingSafeEqual` is the same primitive and works under Bun.
|
|
36
|
+
*
|
|
37
|
+
* Length is checked first because `timingSafeEqual` throws RangeError when the
|
|
38
|
+
* buffers differ in length; unequal length is itself a mismatch, so returning
|
|
39
|
+
* false early leaks nothing that comparing would not.
|
|
39
40
|
*/
|
|
40
41
|
export function verifyToken(expected: string, provided: string): boolean {
|
|
41
42
|
if (!provided || !expected) return false
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
43
|
+
const a = Buffer.from(expected)
|
|
44
|
+
const b = Buffer.from(provided)
|
|
45
|
+
if (a.length !== b.length) return false
|
|
46
|
+
return timingSafeEqual(a, b)
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -31,6 +31,7 @@ import { RulesLoader } from '../core/rules-loader'
|
|
|
31
31
|
import { HookEngine } from '../core/hooks'
|
|
32
32
|
import { loadHookConfigs } from '../core/hooks-config'
|
|
33
33
|
import { loadSettingsJson } from '../config/loader'
|
|
34
|
+
import { getWorkspaceTrust, warnProjectHooksSkipped } from '../core/workspace-trust'
|
|
34
35
|
import { AgentRegistry } from '../agent/agent-registry'
|
|
35
36
|
|
|
36
37
|
export interface DaemonEngineCapabilities {
|
|
@@ -87,11 +88,20 @@ function skillsFor(cwd: string, paths?: string[]): SkillsLoader {
|
|
|
87
88
|
function hooksFor(cwd: string, skills: SkillsLoader): HookEngine {
|
|
88
89
|
const cached = hookCache.get(cwd)
|
|
89
90
|
if (cached) return cached
|
|
90
|
-
|
|
91
|
+
// 会话 cwd 必须显式传:daemon 一个进程服务多个会话,`process.cwd()` 是它自己被
|
|
92
|
+
// 启动时所在的目录,不属于任何会话 —— hooks 会在那里跑,并且**被告知**它在那里。
|
|
93
|
+
const engine = new HookEngine(cwd)
|
|
91
94
|
for (const skill of skills.list()) {
|
|
92
95
|
for (const hook of skill.hooks ?? []) engine.register(hook)
|
|
93
96
|
}
|
|
94
|
-
|
|
97
|
+
// 项目级 hooks 是仓库可控的代码执行面,且 daemon 这条路径**从不**经过交互式信任
|
|
98
|
+
// 询问 —— 而 `isCwdAllowed` 只要求会话 cwd 在 daemon 根目录之内(`workspace-guard.ts:27`
|
|
99
|
+
// `isWithin(cwd, daemonRoot) || isTrusted(cwd)`)⇒ 在未信任的克隆里启动的 daemon,
|
|
100
|
+
// 以前会照着那个仓库的 settings.json 起子进程。未信任即不加载,并说出口。
|
|
101
|
+
const projectHooksTrusted = getWorkspaceTrust().isTrusted(cwd)
|
|
102
|
+
const settings = loadSettingsJson(cwd, { includeProjectHooks: projectHooksTrusted })
|
|
103
|
+
if (settings.projectHooksSkipped) warnProjectHooksSkipped(cwd)
|
|
104
|
+
for (const def of loadHookConfigs(settings.hooks)) engine.register(def)
|
|
95
105
|
hookCache.set(cwd, engine)
|
|
96
106
|
return engine
|
|
97
107
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// engine.close()
|
|
19
19
|
|
|
20
20
|
import type { ClientPromptMessage, ClientInterruptMessage, ServerMessage } from './attach-protocol'
|
|
21
|
-
import type { StreamChunk } from '../shared/types'
|
|
21
|
+
import type { PermissionMode, StreamChunk } from '../shared/types'
|
|
22
22
|
|
|
23
23
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
24
24
|
|
|
@@ -258,11 +258,16 @@ export class RemoteEngine {
|
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
/** Returns a stub permission object so slash commands don't crash. */
|
|
261
|
-
getPermission(): { setMode(_mode:
|
|
261
|
+
getPermission(): { setMode(_mode: PermissionMode): void; getMode(): PermissionMode } {
|
|
262
|
+
// Remote mode: permissions are managed by the daemon, and the attach protocol has
|
|
263
|
+
// no read-back — so `getMode` reports the last mode the user asked for (which is
|
|
264
|
+
// what the footer has always shown here), not a value the daemon never confirmed.
|
|
265
|
+
let requested: PermissionMode = 'default'
|
|
262
266
|
return {
|
|
263
|
-
setMode: (
|
|
264
|
-
|
|
267
|
+
setMode: (mode: PermissionMode) => {
|
|
268
|
+
requested = mode
|
|
265
269
|
},
|
|
270
|
+
getMode: () => requested,
|
|
266
271
|
}
|
|
267
272
|
}
|
|
268
273
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -101,6 +101,10 @@ export function buildDaemonPermission(
|
|
|
101
101
|
for (const msg of permission.getInvalidRules()) {
|
|
102
102
|
process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
|
|
103
103
|
}
|
|
104
|
+
// Sibling channel for malformed restrictions (P1) — same as the CLI entry.
|
|
105
|
+
for (const msg of permission.getInvalidRestrictions()) {
|
|
106
|
+
process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
|
|
107
|
+
}
|
|
104
108
|
return permission
|
|
105
109
|
}
|
|
106
110
|
|
|
@@ -140,6 +144,12 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
140
144
|
dingtalk,
|
|
141
145
|
} = config
|
|
142
146
|
|
|
147
|
+
// Mutable: `POST /api/v1/auth/rotate` replaces the live token. Destructuring
|
|
148
|
+
// `token` above yields a const, so the rotate route could only ever write the
|
|
149
|
+
// new token to disk — the running server kept accepting the old one and
|
|
150
|
+
// rejected the one it had just handed out.
|
|
151
|
+
let activeToken = token
|
|
152
|
+
|
|
143
153
|
const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
|
|
144
154
|
|
|
145
155
|
// Captured once: a later chdir must not move the boundary that callers'
|
|
@@ -362,6 +372,23 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
362
372
|
const server = Bun.serve<WsData>({
|
|
363
373
|
port,
|
|
364
374
|
hostname,
|
|
375
|
+
// The fetch handler below has no top-level try/catch, so anything it throws
|
|
376
|
+
// reaches Bun — and `development` is true unless NODE_ENV=production, which
|
|
377
|
+
// for a daemon bound to MIPHAM_BIND=0.0.0.0 means an unauthenticated peer
|
|
378
|
+
// gets Bun's development error overlay. Measured on bun 1.3.14: with the
|
|
379
|
+
// flag off the body is a 67,154-byte HTML application; with it on, 21 bytes
|
|
380
|
+
// of plain text.
|
|
381
|
+
//
|
|
382
|
+
// Not established, and so not claimed: that the overlay hands back the
|
|
383
|
+
// source. Re-requesting the failing URL with the overlay's own Accept
|
|
384
|
+
// header, guessing the on-disk path, and /bun:info all failed to return the
|
|
385
|
+
// thrown text in a local probe (every unmatched path returned the same
|
|
386
|
+
// shell). What is certain is the shape: a developer debugging UI, shipped
|
|
387
|
+
// with its own JS, served to whoever sent the request.
|
|
388
|
+
//
|
|
389
|
+
// Set explicitly rather than relying on NODE_ENV — how an exposed daemon
|
|
390
|
+
// answers an error must not depend on the user's environment.
|
|
391
|
+
development: false,
|
|
365
392
|
async fetch(req, server) {
|
|
366
393
|
// ── CORS preflight ──────────────────────────────
|
|
367
394
|
const corsResponse = corsMiddleware(req)
|
|
@@ -397,7 +424,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
397
424
|
}
|
|
398
425
|
|
|
399
426
|
// ── Auth check ──────────────────────────────────
|
|
400
|
-
const authError = authMiddleware(req,
|
|
427
|
+
const authError = authMiddleware(req, activeToken, server.requestIP(req)?.address)
|
|
401
428
|
if (authError) return addCorsHeaders(authError, req)
|
|
402
429
|
|
|
403
430
|
// Helper: create JSON response with CORS headers for external origins
|
|
@@ -691,6 +718,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
691
718
|
// ── Auth: rotate token ───────────────────────────
|
|
692
719
|
if (method === 'POST' && path === '/api/v1/auth/rotate') {
|
|
693
720
|
const newToken = rotateToken(tokenPath)
|
|
721
|
+
activeToken = newToken
|
|
694
722
|
return json({ ok: true, data: { token: newToken } })
|
|
695
723
|
}
|
|
696
724
|
|
|
@@ -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
|