@miphamai/cli 0.81.8 → 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 (49) hide show
  1. package/bin/mipham.ts +35 -1
  2. package/package.json +1 -1
  3. package/src/agent/message-bus.ts +10 -3
  4. package/src/agent/sub-agent.ts +60 -12
  5. package/src/agent/types.ts +14 -1
  6. package/src/config/credential-crypto.ts +28 -5
  7. package/src/config/defaults.ts +18 -10
  8. package/src/config/keys-manager.ts +7 -1
  9. package/src/config/loader.ts +202 -63
  10. package/src/core/credential-masker/output-scrub.ts +16 -2
  11. package/src/core/engine.ts +7 -2
  12. package/src/core/hooks-executor.ts +30 -2
  13. package/src/core/hooks.ts +51 -4
  14. package/src/core/paths.ts +44 -1
  15. package/src/core/permission-config.ts +146 -14
  16. package/src/core/permission-rules.ts +17 -2
  17. package/src/core/permission.ts +81 -13
  18. package/src/core/rules-loader.ts +35 -5
  19. package/src/core/session-log.ts +5 -1
  20. package/src/core/workspace-trust.ts +42 -4
  21. package/src/daemon/auth.ts +15 -14
  22. package/src/daemon/engine-capabilities.ts +12 -2
  23. package/src/daemon/remote-engine.ts +9 -4
  24. package/src/daemon/server.ts +29 -1
  25. package/src/i18n-core/locales/en-US.json +12 -8
  26. package/src/i18n-core/locales/zh-CN.json +12 -8
  27. package/src/index.tsx +44 -17
  28. package/src/mcp/client.ts +24 -0
  29. package/src/mcp/http-transport.ts +35 -3
  30. package/src/plugin/plugin-manager.ts +13 -2
  31. package/src/providers/anthropic.ts +48 -11
  32. package/src/security/gate.ts +18 -0
  33. package/src/security/path.ts +19 -1
  34. package/src/shared/arg-validation.ts +37 -2
  35. package/src/shared/package-info.ts +1 -1
  36. package/src/shared/sanitize.ts +27 -2
  37. package/src/shared/types.ts +8 -0
  38. package/src/shared/update.ts +22 -5
  39. package/src/tools/agent/agent.ts +3 -0
  40. package/src/tools/exec/bash.ts +106 -6
  41. package/src/tools/exec/enter-worktree.ts +9 -3
  42. package/src/tools/exec/exit-worktree.ts +6 -3
  43. package/src/tools/exec/git.ts +76 -1
  44. package/src/tools/file/glob.ts +19 -3
  45. package/src/tools/file/grep.ts +33 -3
  46. package/src/tools/index.ts +12 -4
  47. package/src/ui/app.tsx +47 -11
  48. package/src/ui/commands.ts +160 -30
  49. package/src/workflow/primitives/agent.ts +4 -0
@@ -20,21 +20,161 @@ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): Permi
20
20
  }
21
21
 
22
22
  /**
23
- * Permission modes ordered from least to most permissive.
24
- * Used to enforce maxAllowedMode: any mode ranked higher than the cap is forbidden.
23
+ * Nominal permissiveness ranking, least → most permissive. Two consumers only:
24
+ * `maxAllowedMode` (drop every mode ranked above the cap) and `clampMode` (walk
25
+ * downward to the nearest allowed mode below the one requested).
26
+ *
27
+ * **The four modes are not totally ordered in reality**, so this array carries
28
+ * only the relations that are actually measurable:
29
+ *
30
+ * - `plan` is strictly the narrowest. It passes only Read/Grep/Glob and sends
31
+ * *everything* else to approval, while `default` passes every tool that
32
+ * declares `permission: 'auto'` — git, task, web-fetch, cron, memory, … So a
33
+ * cap of `'plan'` must not admit `default`, and `plan` belongs at the bottom.
34
+ * - `acceptEdits` and `default` are **incomparable**: acceptEdits auto-approves
35
+ * Write/Edit and verification-only Bash that `default` asks about, while
36
+ * `default` auto-approves the non-file `'auto'` tools that acceptEdits asks
37
+ * about. No total order is faithful there, so the ranking only needs to carry
38
+ * the relations the two consumers rely on.
39
+ *
40
+ * The array used to read `default → acceptEdits → plan → …`, which **inverted**
41
+ * both `plan` relations rather than merely approximating them: `maxAllowedMode:
42
+ * 'plan'` admitted acceptEdits *and* default — the ceiling let through the wider
43
+ * mode each time. The pairs are pinned by a probe in `test/core/permission.test.ts`
44
+ * (P4) so the claim stays measured rather than asserted.
25
45
  */
26
46
  export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
47
+ 'plan',
27
48
  'default',
28
49
  'acceptEdits',
29
- 'plan',
30
50
  'bypassPermissions',
31
51
  ]
32
52
 
33
- /** Valid mode transition order for Shift+Tab cycling. */
34
- export const MODE_CYCLE: PermissionMode[] = [...PERMISSION_MODE_HIERARCHY]
53
+ /**
54
+ * Shift+Tab cycling order — deliberately **not** the permissiveness order above.
55
+ * The cycle is UX (manual → accept edits → plan → bypass); only the hierarchy
56
+ * answers "is this mode wider than that one". Keeping them separate is what lets
57
+ * `forbiddenModes` drop an entry from the cycle without disturbing the ranking
58
+ * that `clampMode` walks.
59
+ */
60
+ export const MODE_CYCLE: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']
61
+
62
+ /** 规范形 → 把别名与大小写归一到一个键上(键一律小写)。 */
63
+ const MODE_ALIASES: Record<string, PermissionMode> = {
64
+ default: 'default',
65
+ plan: 'plan',
66
+ acceptedits: 'acceptEdits',
67
+ bypasspermissions: 'bypassPermissions',
68
+ bypass: 'bypassPermissions', // 遗留 3 档名(PermissionLevel 里的 'bypass')
69
+ }
70
+
71
+ /** 认不出的配置一律按这一档收紧 —— 层级表首位即最严的一档(与 P4 同一真源)。 */
72
+ const STRICTEST_MODE: PermissionMode = PERMISSION_MODE_HIERARCHY[0]!
73
+
74
+ const VALID_MODE_LIST = 'default, plan, acceptEdits, bypassPermissions'
75
+
76
+ /** 可读的类型名 —— 报错要说清「你给的是个字符串」,而不是只说 invalid。 */
77
+ function describeValue(value: unknown): string {
78
+ if (value === null) return 'null'
79
+ if (Array.isArray(value)) return 'array'
80
+ return typeof value
81
+ }
82
+
83
+ /** 认得出就返回规范形,认不出返回 undefined(调用方负责告警)。 */
84
+ function normalizeModeName(value: unknown): PermissionMode | undefined {
85
+ if (typeof value !== 'string') return undefined
86
+ return MODE_ALIASES[value.trim().toLowerCase()]
87
+ }
88
+
89
+ /**
90
+ * 校验并规范化 `permissionRestrictions`。
91
+ *
92
+ * 与 `getInvalidRules()` 同一形状:写错的**规则**一直会被告警,写错的**限制**却不会
93
+ * —— 而限制写错的失效方向是 **fail-open**:`forbiddenModes` 里的错拼一个模式都匹配
94
+ * 不上;`maxAllowedMode` 认不出时 `indexOf` 返回 -1,`if (capIdx >= 0)` 之后整个上限
95
+ * 被跳过。于是配置里一个 typo 就让整条组织级策略静默失效,且无任何提示。
96
+ *
97
+ * `restrictions` 是规范化后的值(别名与大小写归一、认不出的条目剔除);
98
+ * `invalid` 是逐条可读告警。**只要有任意一条认不出来,就按最严一档封顶** ——
99
+ * 拒绝而不是忽略(忽略就是上面那种 fail-open)。
100
+ *
101
+ * 幂等:已规范化的值再喂一次,`invalid` 必为空(子代理会原样转交一次)。
102
+ */
103
+ export function normalizeRestrictions(raw: unknown): {
104
+ restrictions?: PermissionRestrictions
105
+ invalid: string[]
106
+ } {
107
+ if (raw === undefined || raw === null) return { restrictions: undefined, invalid: [] }
108
+
109
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
110
+ return {
111
+ restrictions: { maxAllowedMode: STRICTEST_MODE },
112
+ invalid: [`permissionRestrictions is not an object (got ${describeValue(raw)})`],
113
+ }
114
+ }
115
+
116
+ const source = raw as Record<string, unknown>
117
+ const invalid: string[] = []
118
+ const forbiddenModes: PermissionMode[] = []
119
+ let maxAllowedMode: PermissionMode | undefined
120
+ let sawForbidden = false
121
+ let sawMaxAllowed = false
122
+
123
+ for (const key of Object.keys(source)) {
124
+ if (key !== 'forbiddenModes' && key !== 'maxAllowedMode') {
125
+ invalid.push(
126
+ `permissionRestrictions has an unknown key "${key}"; valid keys: forbiddenModes, maxAllowedMode`,
127
+ )
128
+ }
129
+ }
130
+
131
+ if (source.forbiddenModes !== undefined) {
132
+ sawForbidden = true
133
+ if (!Array.isArray(source.forbiddenModes)) {
134
+ invalid.push(
135
+ `permissionRestrictions.forbiddenModes must be an array (got ${describeValue(source.forbiddenModes)})`,
136
+ )
137
+ } else {
138
+ source.forbiddenModes.forEach((entry: unknown, i: number) => {
139
+ const mode = normalizeModeName(entry)
140
+ if (mode) forbiddenModes.push(mode)
141
+ else
142
+ invalid.push(
143
+ `permissionRestrictions.forbiddenModes[${i}] is not a permission mode (${JSON.stringify(entry)}); valid: ${VALID_MODE_LIST}`,
144
+ )
145
+ })
146
+ }
147
+ }
148
+
149
+ if (source.maxAllowedMode !== undefined) {
150
+ sawMaxAllowed = true
151
+ const mode = normalizeModeName(source.maxAllowedMode)
152
+ if (mode) maxAllowedMode = mode
153
+ else
154
+ invalid.push(
155
+ `permissionRestrictions.maxAllowedMode is not a permission mode (${JSON.stringify(source.maxAllowedMode)}); valid: ${VALID_MODE_LIST}`,
156
+ )
157
+ }
158
+
159
+ if (invalid.length > 0) {
160
+ // fail-closed:认不出来就按最严一档封顶。识别得出的部分照旧保留。
161
+ invalid.push(
162
+ `permissionRestrictions could not be fully parsed → mode pinned to the strictest ("${STRICTEST_MODE}")`,
163
+ )
164
+ const restrictions: PermissionRestrictions = { maxAllowedMode: STRICTEST_MODE }
165
+ if (forbiddenModes.length > 0) restrictions.forbiddenModes = forbiddenModes
166
+ return { restrictions, invalid }
167
+ }
168
+
169
+ const restrictions: PermissionRestrictions = {}
170
+ if (sawForbidden) restrictions.forbiddenModes = forbiddenModes
171
+ if (sawMaxAllowed) restrictions.maxAllowedMode = maxAllowedMode
172
+ if (Object.keys(restrictions).length === 0) return { restrictions: undefined, invalid: [] }
173
+ return { restrictions, invalid }
174
+ }
35
175
 
36
176
  /** Resolve which modes are actually permitted given the restrictions. */
37
- export function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
177
+ function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
38
178
  let allowed = [...MODE_CYCLE]
39
179
 
40
180
  if (restrictions?.forbiddenModes && restrictions.forbiddenModes.length > 0) {
@@ -52,14 +192,6 @@ export function getAllowedModes(restrictions?: PermissionRestrictions): Permissi
52
192
  return allowed
53
193
  }
54
194
 
55
- /** Check whether a given mode is permitted under the restrictions. */
56
- export function isModeAllowed(
57
- mode: PermissionMode,
58
- restrictions?: PermissionRestrictions,
59
- ): boolean {
60
- return getAllowedModes(restrictions).includes(mode)
61
- }
62
-
63
195
  /**
64
196
  * Return the highest allowed mode at or below `desired` given the restrictions.
65
197
  * Used to silently downgrade when a forbidden mode is requested.
@@ -557,6 +557,7 @@ export function matchBashRule(
557
557
  pattern: string,
558
558
  toolName: string,
559
559
  toolInput: Record<string, unknown>,
560
+ segmentMode: 'any' | 'all' = 'any',
560
561
  ): boolean {
561
562
  // Check if pattern has a parenthesized sub-pattern
562
563
  const parenMatch = pattern.match(/^(\w+)\((.+)\)$/)
@@ -567,6 +568,20 @@ export function matchBashRule(
567
568
 
568
569
  const [, baseTool, subPattern] = parenMatch
569
570
 
571
+ // How a *compound* command is judged when only some parts match:
572
+ // 'any' (deny / ask) — one matching part is enough. Deliberately wide: a
573
+ // deny rule that misses a part is a hole, so `foo && rm -rf /` must be
574
+ // caught by `Bash(rm *)`.
575
+ // 'all' (allow) — every part must match. An allow rule is a *grant*, and
576
+ // granting on one matching part hands over the whole compound command:
577
+ // `Bash(git:*)` plus `git status && rm -rf ./src` used to return
578
+ // `bypass` with no prompt at all.
579
+ // The `length > 0` guard is load-bearing: `[].every()` is `true`, so a
580
+ // command with no matchable segment (or no extracted file access) would
581
+ // otherwise satisfy **any** allow rule — a fail-open of its own.
582
+ const qualifies = (items: string[], match: (s: string) => boolean): boolean =>
583
+ segmentMode === 'all' ? items.length > 0 && items.every(match) : items.some(match)
584
+
570
585
  // A Read/Write/Edit rule must also refuse a Bash command that touches the
571
586
  // same file (via a reader/editor command or a redirect), not only the
572
587
  // Read/Write/Edit tool itself. Otherwise `cat .git-credentials` bypasses a
@@ -575,7 +590,7 @@ export function matchBashRule(
575
590
  const cmd = String(toolInput.command || '')
576
591
  const access = extractBashFileAccess(cmd)
577
592
  const paths = baseTool === 'Read' ? access.read : access.write
578
- return paths.some((p) => matchPath(p, subPattern!))
593
+ return qualifies(paths, (p) => matchPath(p, subPattern!))
579
594
  }
580
595
 
581
596
  if (toolName !== baseTool!) return false
@@ -585,7 +600,7 @@ export function matchBashRule(
585
600
  // `$(...)`/backtick substitution, so `Bash(rm *)` catches `x=$(rm -rf ~)`).
586
601
  if (baseTool === 'Bash') {
587
602
  const cmd = String(toolInput.command || '')
588
- return flattenCommand(cmd).some((seg) => wildcardMatch(subPattern!, seg))
603
+ return qualifies(flattenCommand(cmd), (seg) => wildcardMatch(subPattern!, seg))
589
604
  }
590
605
 
591
606
  // For Write/Edit/Read: match against the file_path with path-glob semantics.
@@ -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: [
@@ -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