@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
@@ -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 { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
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
- /** Apply org-level permission restrictions. Overwrites any previous restrictions. */
134
- setRestrictions(restrictions: PermissionRestrictions | undefined): void {
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
- this.mode = clampMode(config.mode, config.restrictions ?? this.restrictions)
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: PermissionLevel = 'bypass'
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 (this.mode) {
482
+ switch (mode) {
415
483
  case 'default':
416
484
  // Delegate to tool.permission (backward compat)
417
485
  return 'mode-baseline'
@@ -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
- if (!existsSync(this.rulesDir)) return
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 files = readdirSync(this.rulesDir).filter((f) => f.endsWith('.md'))
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 filepath = join(this.rulesDir, file)
77
+ const name = file.replace(/\.md$/, '')
78
+ if (seen.has(name)) continue
50
79
  try {
51
- const raw = readFileSync(filepath, 'utf-8')
80
+ const raw = readFileSync(join(dir, file), 'utf-8')
52
81
  const { paths, description, content } = this.parseRule(raw, file)
53
- this.rules.push({ name: file.replace(/\.md$/, ''), paths, description, content })
82
+ this.rules.push({ name, paths, description, content })
83
+ seen.add(name)
54
84
  } catch {
55
85
  // Skip unparseable files
56
86
  }
@@ -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
- const content = result.success ? result.content : result.error || result.content
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
- log.buf.push(JSON.parse(trimmed) as SessionEvent)
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
- const session = SessionStore.load(name)
228
+ let session: StoredSession | null = null
229
+ try {
230
+ session = SessionStore.load(name)
231
+ } catch {
232
+ continue
233
+ }
224
234
  if (session?.metadata) {
225
235
  sessions.push(session.metadata)
226
236
  }
@@ -1,6 +1,7 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
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
- if (parsed.version !== 1) {
71
- // Unknown version — reset
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
- writeFileSync(TRUST_STORE_PATH, JSON.stringify(this.store, null, 2), 'utf-8')
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
+ }
@@ -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 Bun's constant-time comparison to prevent timing attacks.
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
- return (Bun.password as unknown as PasswordWithCompare).constantTimeCompare(
43
- Buffer.from(expected),
44
- Buffer.from(provided),
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
- const engine = new HookEngine()
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
- for (const def of loadHookConfigs(loadSettingsJson(cwd).hooks)) engine.register(def)
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: string): void } {
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: (_mode: string) => {
264
- // Remote mode: permissions are managed by the daemon
267
+ setMode: (mode: PermissionMode) => {
268
+ requested = mode
265
269
  },
270
+ getMode: () => requested,
266
271
  }
267
272
  }
268
273
 
@@ -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, token, server.requestIP(req)?.address)
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