@miphamai/cli 0.85.1 → 0.85.3

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 (63) hide show
  1. package/bin/mipham.ts +42 -2
  2. package/package.json +1 -1
  3. package/skills/standard/mipham-code-setup.SKILL.md +9 -2
  4. package/src/agent/agent-experience.ts +3 -2
  5. package/src/agent/background-registry.ts +7 -3
  6. package/src/agent/cross-session/discovery.ts +5 -12
  7. package/src/agent/cross-session/file-inbox.ts +2 -2
  8. package/src/agent/effectiveness-tracker.ts +3 -2
  9. package/src/agent/sub-agent.ts +67 -6
  10. package/src/agent/types.ts +10 -0
  11. package/src/agent-view/agent-view-manager.ts +46 -0
  12. package/src/agent-view/dashboard.tsx +106 -16
  13. package/src/agent-view/session-view.tsx +128 -0
  14. package/src/commands/autoloop-journal.ts +6 -5
  15. package/src/commands/environment.ts +11 -8
  16. package/src/commands/project.ts +5 -3
  17. package/src/config/credential-crypto.ts +13 -1
  18. package/src/config/keys-manager.ts +7 -4
  19. package/src/config/loader.ts +144 -41
  20. package/src/config/preferences.ts +6 -3
  21. package/src/core/constitution-loader.ts +3 -2
  22. package/src/core/context.ts +35 -6
  23. package/src/core/crsi-producer.ts +4 -2
  24. package/src/core/crsi-sandbox.ts +2 -1
  25. package/src/core/dream-engine.ts +5 -11
  26. package/src/core/engine.ts +9 -4
  27. package/src/core/error-signature-db.ts +3 -2
  28. package/src/core/eval-harness.ts +3 -3
  29. package/src/core/hooks-executor.ts +60 -2
  30. package/src/core/instructions.ts +69 -48
  31. package/src/core/memory/memory-manager.ts +10 -6
  32. package/src/core/permission-audit.ts +13 -6
  33. package/src/core/permission-config.ts +28 -7
  34. package/src/core/permission-rules.ts +99 -5
  35. package/src/core/permission.ts +6 -1
  36. package/src/core/rule-engine.ts +3 -2
  37. package/src/core/session-log.ts +48 -11
  38. package/src/core/session-store.ts +64 -44
  39. package/src/daemon/attach-protocol.ts +30 -3
  40. package/src/daemon/auth.ts +4 -3
  41. package/src/daemon/index.ts +4 -3
  42. package/src/daemon/remote-engine.ts +173 -24
  43. package/src/daemon/server.ts +51 -1
  44. package/src/daemon/session-worker.ts +34 -1
  45. package/src/i18n-core/locales/en-US.json +1 -0
  46. package/src/i18n-core/locales/zh-CN.json +1 -0
  47. package/src/index.tsx +75 -17
  48. package/src/mcp/oauth.ts +47 -5
  49. package/src/mcp/token-store.ts +10 -11
  50. package/src/providers/openai-compat.ts +11 -0
  51. package/src/shared/arg-validation.ts +74 -2
  52. package/src/shared/package-info.ts +1 -1
  53. package/src/shared/regular-file.ts +63 -0
  54. package/src/shared/sanitize.ts +14 -2
  55. package/src/skills/bundled-skills.ts +1 -1
  56. package/src/tools/agent/memory.ts +4 -2
  57. package/src/tools/agent/workflow.ts +6 -3
  58. package/src/tools/exec/task.ts +82 -30
  59. package/src/tools/scheduling/cron.ts +3 -9
  60. package/src/ui/app.tsx +78 -14
  61. package/src/ui/commands.ts +73 -31
  62. package/src/ui/ctrl-c-confirm.ts +63 -0
  63. package/src/workflow/journal.ts +80 -26
@@ -106,6 +106,55 @@ export function parseHookStdout(stdout: string | null | undefined, _ctx: HookCon
106
106
  return { allowed: true }
107
107
  }
108
108
 
109
+ /**
110
+ * Why the hook subprocess failed to run to completion, or `null` if it did exit.
111
+ *
112
+ * `spawnSync` does **not** throw for a failed spawn or a timeout — it reports them
113
+ * on the result, and all three shapes arrive with **empty stderr**: `error.code`
114
+ * is `ETIMEDOUT` for the timeout, `ENOENT` when the command does not exist, and an
115
+ * externally killed child comes back as `status: null` + `signal` with no `error`
116
+ * at all. Empty stderr is what made them one string with a benign non-zero exit
117
+ * that printed nothing — `Hook warning (<cmd>): ` with the reason left blank.
118
+ *
119
+ * A fourth shape is **not** a failure of the run at all: `EPIPE` on the write that
120
+ * hands the child its stdin payload, which is what happens whenever the child
121
+ * exits without reading it (measured: `status` survives — `exit 0` → 0, `exit 3`
122
+ * → 3 — and `signal` stays null). Hook commands routinely ignore stdin, and whether
123
+ * that write loses its race is a property of the scheduler, not of the hook: it
124
+ * never occurred on the dev machine and reddened CI on a faster one. Read as the
125
+ * cause, it **replaced** the child's real result — `Hook warning (<cmd>): boom`
126
+ * and `killed by SIGTERM` both came back as `Hook error (<cmd>): EPIPE`. So EPIPE
127
+ * is exempt from the two decisions below, and speaks only when nothing else can.
128
+ *
129
+ * Named here rather than in the caller's `catch`, which cannot see any of them:
130
+ * it runs only when `spawnSync` itself throws.
131
+ */
132
+ function spawnFailureCause(
133
+ result: { status?: number | null; signal?: string | null; error?: unknown },
134
+ timeoutSeconds: number,
135
+ ): string | null {
136
+ const err = result.error as { code?: string; message?: string } | undefined
137
+ const hasExit = typeof result.status === 'number'
138
+ const writeFailed = err?.code === 'EPIPE'
139
+
140
+ if (err && !writeFailed) {
141
+ if (err.code === 'ETIMEDOUT') return `timed out after ${timeoutSeconds}s`
142
+ return err.message
143
+ ? `${err.code ?? 'spawn failed'}: ${err.message}`
144
+ : (err.code ?? 'spawn failed')
145
+ }
146
+
147
+ if (result.signal && !hasExit) {
148
+ return `killed by ${result.signal}`
149
+ }
150
+
151
+ // No branch for "EPIPE and nothing else": a failed write presupposes a child that
152
+ // was spawned and reaped, so `status` or `signal` is always present alongside it
153
+ // (`exit 0`/`exit 3` → 0/3, `kill -TERM $$` → SIGTERM). Unreachable error
154
+ // handling is what the exemption above is meant to avoid, not to add.
155
+ return null
156
+ }
157
+
109
158
  async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
110
159
  if (!cfg.command) return { allowed: true }
111
160
 
@@ -167,16 +216,25 @@ async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookRe
167
216
  }
168
217
  }
169
218
 
219
+ const failure = spawnFailureCause(result, cfg.timeout ?? 60)
220
+ if (failure) {
221
+ return {
222
+ allowed: true,
223
+ additionalContext: `Hook error (${cfg.command}): ${failure}`,
224
+ }
225
+ }
226
+
170
227
  // Other non-zero exit: don't block, log the error as context
171
228
  return {
172
229
  allowed: true,
173
230
  additionalContext: `Hook warning (${cfg.command}): ${stderr.trim()}`,
174
231
  }
175
232
  } catch (err) {
176
- // spawnSync errors (e.g., command not found, timeout, signal)
233
+ // Only reached when `spawnSync` itself throws — masking-policy load, env
234
+ // filter, or an option it rejects outright. Its comment used to name timeouts
235
+ // and missing commands, neither of which can arrive here.
177
236
  const message = (err as { message?: string }).message || String(err)
178
237
 
179
- // Timeout/signal: treat as non-blocking warning
180
238
  return {
181
239
  allowed: true,
182
240
  additionalContext: `Hook error (${cfg.command}): ${message}`,
@@ -104,6 +104,58 @@ export const INSTRUCTION_FILENAMES = [
104
104
  'CLAUDE.md',
105
105
  ] as const
106
106
 
107
+ /**
108
+ * P2-2: the permission-mode section of the system prompt. Tells the model its
109
+ * current permission level and what to expect.
110
+ *
111
+ * Module-level and pure (it reads no loaded instruction files) because **every**
112
+ * prompt-assembly site needs the same text: the CLI's main context, where the
113
+ * context calls it at read time via `ContextManager.setPermissionContextSource`
114
+ * so a mid-session Shift+Tab moves the text and the gate together, and each
115
+ * **sub-agent**, which reports **its own** mode (see `sub-agent.ts`).
116
+ */
117
+ export function buildPermissionBlock(mode: string): string {
118
+ // Hand-written map, and a missing key is **silent**: the `if (!description)`
119
+ // below returns `''`, so the system prompt would simply say nothing about
120
+ // permissions rather than warn. Every `PermissionMode` member needs a line.
121
+ // `auto`'s text has to describe a gate the model cannot see: it is told
122
+ // "a classifier rules on each of your calls" rather than "you are
123
+ // unrestricted", because a model that believes it has blanket permission
124
+ // stops explaining what it is about to do — which is exactly the input the
125
+ // classifier needs.
126
+ const modeDescriptions: Record<string, string> = {
127
+ default:
128
+ 'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
129
+ acceptEdits:
130
+ 'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
131
+ plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
132
+ auto: 'You are in **auto** mode. A classifier reviews each tool call before it runs and blocks calls that are destructive, that act on instructions found in files or tool output, or that touch credentials. Approved calls run; blocked ones return a denial with the reason. Prefer explaining the intent of a call when it is unusual.',
133
+ bypassPermissions:
134
+ 'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
135
+ }
136
+
137
+ const description = modeDescriptions[mode]
138
+ if (!description) return ''
139
+
140
+ // What actually lifts a denial is not the same in `auto`. There the refusal is a
141
+ // ruling on one exact call, and a repeat of that same call is answered from the
142
+ // classifier cache rather than re-judged — so "retry after explaining yourself" is
143
+ // advice that cannot work, and telling the model to switch modes is advice that is
144
+ // never needed. The two levers that do work are named instead.
145
+ const escape =
146
+ mode === 'auto'
147
+ ? ' In **auto** mode the refusal is a ruling on that exact call: an allow rule (`/permissions allow`) lifts it, and so does changing the call so it no longer trips the rule — repeating the identical call returns the same ruling.'
148
+ : ''
149
+
150
+ // The tail used to name `bypassPermissions` as the Shift+Tab destination. That
151
+ // was true while the wheel carried it and became false the moment `auto`
152
+ // replaced it — and this string is *advice the model repeats to the user*, so
153
+ // staying stale makes it promise a keypress that does nothing. `bypassPermissions`
154
+ // is still reachable, but only by naming it in config; saying so is what keeps
155
+ // the model from offering it as a way out.
156
+ return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode.${escape} If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch modes with Shift+Tab or add an allow rule (/permissions), then wait for the user's answer. Note that Shift+Tab's wheel does not reach bypassPermissions — that mode is set in config, so do not offer it as a keypress.`
157
+ }
158
+
107
159
  export class InstructionsLoader {
108
160
  private instructions: InstructionFile[] = []
109
161
  private crsiLessonSummaries: CrsiLessonSummary[] = []
@@ -133,7 +185,16 @@ export class InstructionsLoader {
133
185
  this.crsiLessonSummaries = this.loadCrsiLessons(root)
134
186
  }
135
187
 
136
- buildSystemPrompt(permissionMode?: string): string {
188
+ /**
189
+ * The base prompt. **Deliberately takes no permission mode** — the mode is not a
190
+ * component of the prompt that gets assembled once, it is the answer to "which gate
191
+ * will run this call", and that answer changes mid-session (Shift+Tab). Baking it in
192
+ * here froze a copy: narrowing was self-correcting (the model obeys a gate that is now
193
+ * wider than it was told), but *widening* left the model refusing work it was already
194
+ * allowed to do. `ContextManager.setPermissionContextSource` derives the section on
195
+ * every read instead, so `permission.getMode()` is never sampled once and cached.
196
+ */
197
+ buildSystemPrompt(): string {
137
198
  const parts: string[] = []
138
199
 
139
200
  for (const inst of this.instructions) {
@@ -156,11 +217,8 @@ export class InstructionsLoader {
156
217
  parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${content}`)
157
218
  }
158
219
 
159
- // P2-2: Inject current permission mode so the model knows its constraints
160
- if (permissionMode) {
161
- parts.push(this.buildPermissionContext(permissionMode))
162
- }
163
-
220
+ // P2-2 的权限段**不在**这里 —— 见 `buildPermissionBlock` 与
221
+ // `ContextManager.setPermissionContextSource`(读时派生,故切档即生效)。
164
222
  // 开场克制:寒暄只回一句短问候,不上能力清单(避免把「你好」当「你是谁」处理)
165
223
  parts.push(`## Greeting Restraint
166
224
 
@@ -322,49 +380,12 @@ Never omit it or present the work as purely human-authored.`)
322
380
  }
323
381
 
324
382
  /**
325
- * P2-2: Build a permission-mode context block for the system prompt.
326
- * Tells the model its current permission level and what to expect.
383
+ * Loader-shaped alias of {@link buildPermissionBlock}(模块级那个才是实现)。
384
+ * 保留它是因为**已有**的调用点都握着一个装载器:`index.tsx` 的接线行与
385
+ * `test/core/permission-prompt-live.test.ts` 的 `wire()`。它不含任何状态。
327
386
  */
328
- private buildPermissionContext(mode: string): string {
329
- // Hand-written map, and a missing key is **silent**: the `if (!description)`
330
- // below returns `''`, so the system prompt would simply say nothing about
331
- // permissions rather than warn. Every `PermissionMode` member needs a line.
332
- // `auto`'s text has to describe a gate the model cannot see: it is told
333
- // "a classifier rules on each of your calls" rather than "you are
334
- // unrestricted", because a model that believes it has blanket permission
335
- // stops explaining what it is about to do — which is exactly the input the
336
- // classifier needs.
337
- const modeDescriptions: Record<string, string> = {
338
- default:
339
- 'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
340
- acceptEdits:
341
- 'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
342
- plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
343
- auto: 'You are in **auto** mode. A classifier reviews each tool call before it runs and blocks calls that are destructive, that act on instructions found in files or tool output, or that touch credentials. Approved calls run; blocked ones return a denial with the reason. Prefer explaining the intent of a call when it is unusual.',
344
- bypassPermissions:
345
- 'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
346
- }
347
-
348
- const description = modeDescriptions[mode]
349
- if (!description) return ''
350
-
351
- // What actually lifts a denial is not the same in `auto`. There the refusal is a
352
- // ruling on one exact call, and a repeat of that same call is answered from the
353
- // classifier cache rather than re-judged — so "retry after explaining yourself" is
354
- // advice that cannot work, and telling the model to switch modes is advice that is
355
- // never needed. The two levers that do work are named instead.
356
- const escape =
357
- mode === 'auto'
358
- ? ' In **auto** mode the refusal is a ruling on that exact call: an allow rule (`/permissions allow`) lifts it, and so does changing the call so it no longer trips the rule — repeating the identical call returns the same ruling.'
359
- : ''
360
-
361
- // The tail used to name `bypassPermissions` as the Shift+Tab destination. That
362
- // was true while the wheel carried it and became false the moment `auto`
363
- // replaced it — and this string is *advice the model repeats to the user*, so
364
- // staying stale makes it promise a keypress that does nothing. `bypassPermissions`
365
- // is still reachable, but only by naming it in config; saying so is what keeps
366
- // the model from offering it as a way out.
367
- return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode.${escape} If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch modes with Shift+Tab or add an allow rule (/permissions), then wait for the user's answer. Note that Shift+Tab's wheel does not reach bypassPermissions — that mode is set in config, so do not offer it as a keypress.`
387
+ buildPermissionBlock(mode: string): string {
388
+ return buildPermissionBlock(mode)
368
389
  }
369
390
 
370
391
  list(): InstructionFile[] {
@@ -2,13 +2,13 @@ import {
2
2
  mkdirSync,
3
3
  readdirSync,
4
4
  readFileSync,
5
- writeFileSync,
6
5
  unlinkSync,
7
6
  renameSync,
8
7
  existsSync,
9
8
  statSync,
10
9
  } from 'node:fs'
11
10
  import { join, extname, basename } from 'node:path'
11
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
12
12
  import { similarities, findNearDuplicates } from './tfidf'
13
13
 
14
14
  export interface MemoryMetadata {
@@ -135,7 +135,9 @@ export class MemoryManager {
135
135
  const filePath = join(this.memoryDir, fileName)
136
136
  const formattedBody = this.formatMemoryBody(metadata, content)
137
137
  const body = this.formatMemoryFile(name, metadata, content)
138
- writeFileSync(filePath, body, 'utf-8')
138
+ // 原子写:从前是裸 writeFileSync,写到一半被杀留下的半截 JSON 会被读侧的 catch 当成
139
+ // 「文件损坏」清空 —— 一次崩溃赔上全部记忆/规则/统计,不是丢一条。
140
+ atomicWriteFileSync(filePath, body, { mode: 0o644 })
139
141
 
140
142
  const entry: MemoryEntry = {
141
143
  name,
@@ -158,7 +160,9 @@ export class MemoryManager {
158
160
  entry.description = metadata.relevance.join(', ')
159
161
  entry.updatedAt = new Date()
160
162
  const body = this.formatMemoryFile(entry.name, metadata, content)
161
- writeFileSync(entry.filePath, body, 'utf-8')
163
+ // 原子写:从前是裸 writeFileSync,写到一半被杀留下的半截 JSON 会被读侧的 catch 当成
164
+ // 「文件损坏」清空 —— 一次崩溃赔上全部记忆/规则/统计,不是丢一条。
165
+ atomicWriteFileSync(entry.filePath, body, { mode: 0o644 })
162
166
  this.updateWikilinks(entry.name, content)
163
167
  this.updateIndex()
164
168
  }
@@ -465,7 +469,7 @@ export class MemoryManager {
465
469
  lines.push(`- [${entry.name}](${entry.name}.md) — ${entry.description}`)
466
470
  }
467
471
 
468
- writeFileSync(join(this.memoryDir, INDEX_FILE), lines.join('\n') + '\n', 'utf-8')
472
+ atomicWriteFileSync(join(this.memoryDir, INDEX_FILE), lines.join('\n') + '\n', { mode: 0o644 })
469
473
  }
470
474
 
471
475
  private extractWikilinks(content: string): string[] {
@@ -532,7 +536,7 @@ export class MemoryManager {
532
536
  obj[k] = Array.from(v)
533
537
  }
534
538
  try {
535
- writeFileSync(join(this.memoryDir, LINKS_FILE), JSON.stringify(obj, null, 2), 'utf-8')
539
+ atomicWriteFileSync(join(this.memoryDir, LINKS_FILE), JSON.stringify(obj, null, 2))
536
540
  } catch {
537
541
  // best-effort — never block on cache write
538
542
  }
@@ -573,7 +577,7 @@ export class MemoryManager {
573
577
  const obj: Record<string, { recallCount: number; lastRecalledAt: string }> = {}
574
578
  for (const [k, v] of this.recallStats) obj[k] = v
575
579
  try {
576
- writeFileSync(join(this.memoryDir, RECALL_STATS_FILE), JSON.stringify(obj, null, 2), 'utf-8')
580
+ atomicWriteFileSync(join(this.memoryDir, RECALL_STATS_FILE), JSON.stringify(obj, null, 2))
577
581
  } catch {
578
582
  // best-effort — never block on stats write
579
583
  }
@@ -31,10 +31,11 @@
31
31
  * 「分类器裁过什么」,不是「某条命令跑了几次」;执行次数要问 gate 侧的指标或会话日志。
32
32
  */
33
33
 
34
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
34
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
35
35
  import { dirname } from 'node:path'
36
36
  import type { PermissionLevel, PermissionMode } from '../shared/index.ts'
37
37
  import type { PermissionDenialReason } from './permission'
38
+ import { appendRegularFileSync } from '../shared/regular-file'
38
39
  import { miphamHome } from './paths.ts'
39
40
 
40
41
  /** 一条分类器裁决。 */
@@ -85,11 +86,17 @@ export function recordClassifierRuling(record: Omit<ClassifierRulingRecord, 'at'
85
86
  try {
86
87
  const file = permissionAuditPath()
87
88
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
88
- appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...record }) + '\n', {
89
- encoding: 'utf-8',
90
- // 只在创建时生效;已存在的文件不会被改权限。
91
- mode: 0o600,
92
- })
89
+ // 台账路径上是个 FIFO 时**不写**也**不挂**(`appendFileSync` 会打开它写 ⇒ 等到有
90
+ // 读者为止):当成一次写失败,走下面那条「第一次说一句」的路。
91
+ if (
92
+ !appendRegularFileSync(
93
+ file,
94
+ JSON.stringify({ at: new Date().toISOString(), ...record }) + '\n',
95
+ { mode: 0o600 },
96
+ )
97
+ ) {
98
+ throw new Error(`${file} is not a regular file`)
99
+ }
93
100
  } catch (err) {
94
101
  if (!warnedOnce) {
95
102
  warnedOnce = true
@@ -83,10 +83,31 @@ export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
83
83
  * `forbiddenModes` / `maxAllowedMode` are applied to.
84
84
  *
85
85
  * Deliberately a separate array from `MODE_CYCLE`, and deliberately able to be a
86
- * **superset** of it: `bypassPermissions` is reachable through config /
87
- * `MIPHAM_DAEMON_PERMISSION` / settings without being something a user can
88
- * Shift+Tab into. Claude Code arranges it the same way — its descriptor table
89
- * lists `bypassPermissions` while its cycle array does not.
86
+ * **superset** of it: `bypassPermissions` is reachable by naming it at one of the
87
+ * doors listed below, without being something a user can Shift+Tab into. Claude
88
+ * Code arranges it the same way — its descriptor table lists
89
+ * `bypassPermissions` while its cycle array does not.
90
+ *
91
+ * **The doors, exhaustively** (this list is the point of the entry — a mode that
92
+ * is legal here and refused at some other door is a mode the user cannot reach
93
+ * and cannot get a reason for):
94
+ *
95
+ * 1. `~/.mipham/config.yml` → `permission: <mode>` (native key; may be the
96
+ * first-run wizard's `default`, so it is ranked *below* 2)
97
+ * 2. `~/.mipham/settings.json` → `permissions.defaultMode` (the adopted
98
+ * upstream key — user level **only**, see 5)
99
+ * 3. `mipham --permission <mode>` — this invocation's word, beats both files
100
+ * 4. `MIPHAM_DAEMON_PERMISSION` — the daemon's gate; the only door the daemon
101
+ * reads at all (`resolveDaemonPermission`)
102
+ * 5. …and nothing else. Project-level `.mipham/config.yml` /
103
+ * `.mipham/settings.json` are **not** doors: those files arrive with the
104
+ * code, so whoever wrote the repository would be choosing the approval gate.
105
+ * A mode declared there is withheld and reported, not applied.
106
+ *
107
+ * Every door lands on the same applier (`PermissionSystem.setDefaultLevel`), which
108
+ * is why this array is also the accepted *value* domain — a second validator would
109
+ * be a second domain, and the day a mode is added the two would disagree at one of
110
+ * the five doors.
90
111
  *
91
112
  * **The two arrays must not be collapsed back into one.** `getAllowedModes`
92
113
  * filters *this* array, never `MODE_CYCLE`. If it filtered the cycle, then the
@@ -115,9 +136,9 @@ export const ALL_MODES: PermissionMode[] = [
115
136
  * disturbing the ranking that `clampMode` walks.
116
137
  *
117
138
  * The two arrays **differ**, and that is the whole reason both exist:
118
- * `bypassPermissions` is a legal mode that no Shift+Tab reaches (asked for
119
- * through config / `MIPHAM_DAEMON_PERMISSION` / settings, where the user named
120
- * it explicitly), while `auto` is on the wheel. Collapsing them back into one
139
+ * `bypassPermissions` is a legal mode that no Shift+Tab reaches (asked for by
140
+ * name at one of the doors listed under `ALL_MODES`, where the user named it
141
+ * explicitly), while `auto` is on the wheel. Collapsing them back into one
121
142
  * would either drop a legal mode or advertise one the wheel cannot reach —
122
143
  * **do not "simplify" one back into the other.**
123
144
  *
@@ -1,7 +1,8 @@
1
1
  import { homedir } from 'node:os'
2
- import { join } from 'node:path'
2
+ import { isAbsolute, join, normalize } from 'node:path'
3
+ import { realpathSync } from 'node:fs'
3
4
  import type { PermissionRuleEntry } from '../shared/index.ts'
4
- import { matchPath } from './credential-masker/matcher'
5
+ import { expandHome, matchPath } from './credential-masker/matcher'
5
6
 
6
7
  // ── Bash command analysis (Read/Write/Edit deny-rule extension) ──
7
8
  //
@@ -543,6 +544,99 @@ function scanReaderWriterCommands(
543
544
  */
544
545
  const PARAMETERISED_TOOLS = new Set(['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob'])
545
546
 
547
+ /**
548
+ * The **landing** spelling of a path *or a pattern*: the realpath of its
549
+ * longest **existing** prefix, with everything after that prefix kept verbatim.
550
+ * Returns the input unchanged when nothing along it exists.
551
+ *
552
+ * A prefix walk rather than a bare `realpathSync`, for two reasons:
553
+ *
554
+ * - A rule may legitimately name a leaf that does not exist yet
555
+ * (`Write(/tmp/new.txt)`) — `realpathSync` on it throws, so a leaf-only
556
+ * resolve would decide that a path the tool is about to create has no
557
+ * landing at all.
558
+ * - macOS resolves `/tmp` → `/private/tmp` (likewise `/etc`, `/var`), so a
559
+ * pattern has to be resolved **the same way as the path** or `/tmp/**` stops
560
+ * matching its own files. Resolving only one side is what turns a tightening
561
+ * into a silent refusal.
562
+ *
563
+ * A prefix whose segments carry a glob metacharacter is not a path on disk
564
+ * (`/tmp/*` denotes "whatever is under /tmp"), so the walk steps over it: the
565
+ * glob-free prefix gets resolved and `*` / `**` / `?` survive verbatim.
566
+ */
567
+ function resolveLanding(p: string): string {
568
+ const parts = expandHome(p).replace(/\\/g, '/').split('/')
569
+ for (let i = parts.length; i > 0; i--) {
570
+ const prefix = parts.slice(0, i).join('/')
571
+ if (!prefix || /[*?]/.test(prefix)) continue
572
+ try {
573
+ const real = realpathSync(prefix)
574
+ const rest = parts.slice(i).join('/')
575
+ return rest ? `${real}/${rest}` : real
576
+ } catch {
577
+ // Not on disk — try its parent.
578
+ }
579
+ }
580
+ return p
581
+ }
582
+
583
+ /**
584
+ * Where `p` would read if nothing along it were a symlink — the same string
585
+ * spelled without touching the disk. Used only to ask "did resolving this path
586
+ * change anything", never as a match candidate.
587
+ */
588
+ function lexicallySpelled(p: string): string {
589
+ const expanded = expandHome(p).replace(/\\/g, '/')
590
+ if (isAbsolute(expanded) || /^[A-Za-z]:\//.test(expanded)) return normalize(expanded)
591
+ return normalize(join(process.cwd(), expanded))
592
+ }
593
+
594
+ /**
595
+ * Match a path rule against the path an operation *lands* on, not just the one
596
+ * it was spelled with. `notes.txt` symlinked to `.env` **is** a read of `.env`,
597
+ * and the rule only ever sees the spelling the model sent.
598
+ *
599
+ * Deny/ask (`segmentMode === 'any'`): a miss is a hole, and the resolved form
600
+ * can only *add* matches — this direction is strictly fail-closed, and cannot
601
+ * un-protect anything protected today.
602
+ *
603
+ * Allow (`'all'`) is the direction where a grant can leak, so the landing has to
604
+ * be inside the same grant: an allow rule granting every `.txt` file no longer
605
+ * auto-approves a `.txt` symlink that lands on `.env`. The extra judgement is
606
+ * gated on the path actually having been redirected — if resolving it changes
607
+ * nothing, there is no second spelling to disagree about and the decision is
608
+ * identical to the literal one. Patterns are resolved on the same axes as paths
609
+ * precisely so that gate does not misfire on `/tmp/**`, on a pattern naming a
610
+ * symlink on purpose, or on a leaf that does not exist yet.
611
+ */
612
+ function matchPathRule(path: string, pattern: string, segmentMode: 'any' | 'all'): boolean {
613
+ const literal = matchPath(path, pattern)
614
+ const landing = resolveLanding(path)
615
+ // The pattern canonicalised on the same axes as the path (`/tmp/**` →
616
+ // `/private/tmp/**`). `expandHome` first: `matchPath` expands the pattern but
617
+ // never the path, so a `~` pattern has to be absolute before it is resolved.
618
+ const canon = resolveLanding(expandHome(pattern))
619
+
620
+ if (segmentMode === 'all') {
621
+ if (!literal) return false
622
+ if (landing === lexicallySpelled(path)) return true
623
+ return matchPath(landing, canon)
624
+ }
625
+
626
+ if (literal) return true
627
+ if (matchPath(landing, canon)) return true
628
+ try {
629
+ // The pre-existing check, kept verbatim so this direction stays a
630
+ // mechanical superset of what it used to match (it differs only for a path
631
+ // whose own name contains a glob character).
632
+ return matchPath(realpathSync(path), expandHome(pattern))
633
+ } catch {
634
+ // Nothing on disk to resolve (ENOENT, EACCES, a path the model invented) —
635
+ // there is no second spelling to try, and the literal already missed.
636
+ return false
637
+ }
638
+ }
639
+
546
640
  // Match a tool(parameter) rule against an actual tool call.
547
641
  //
548
642
  // Pattern formats:
@@ -590,7 +684,7 @@ export function matchBashRule(
590
684
  const cmd = String(toolInput.command || '')
591
685
  const access = extractBashFileAccess(cmd)
592
686
  const paths = baseTool === 'Read' ? access.read : access.write
593
- return qualifies(paths, (p) => matchPath(p, subPattern!))
687
+ return qualifies(paths, (p) => matchPathRule(p, subPattern!, segmentMode))
594
688
  }
595
689
 
596
690
  if (toolName !== baseTool!) return false
@@ -609,13 +703,13 @@ export function matchBashRule(
609
703
  // paths — `*` would cross `/` and Windows drive letters like `C:\` get mangled.
610
704
  if (baseTool === 'Write' || baseTool === 'Edit' || baseTool === 'Read') {
611
705
  const path = String(toolInput.file_path || '')
612
- return matchPath(path, subPattern!)
706
+ return matchPathRule(path, subPattern!, segmentMode)
613
707
  }
614
708
 
615
709
  // For Grep/Glob: match against the base search path (a directory)
616
710
  if (baseTool === 'Grep' || baseTool === 'Glob') {
617
711
  const path = String(toolInput.path || '')
618
- return matchPath(path, subPattern!)
712
+ return matchPathRule(path, subPattern!, segmentMode)
619
713
  }
620
714
 
621
715
  return false
@@ -205,7 +205,12 @@ export class PermissionSystem {
205
205
 
206
206
  // ── P1-4: Consecutive block counter (prevents infinite retry loops) ──
207
207
  private consecutiveBlockCount = 0
208
- private static readonly MAX_CONSECUTIVE_BLOCKS = 3
208
+ /**
209
+ * Shared by both tool loops — `Engine.executeTool` and the sub-agent's own
210
+ * turn loop (which bypasses the engine and reimplements the permission step).
211
+ * Public so the second reader cannot drift to its own number.
212
+ */
213
+ static readonly MAX_CONSECUTIVE_BLOCKS = 3
209
214
 
210
215
  /** Invalidate the permission cache (called on any rule/mode change). */
211
216
  private invalidateCache(): void {
@@ -1,5 +1,6 @@
1
1
  import type { ExperienceRule } from '../agent/experience-rules.js'
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
2
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs'
3
+ import { atomicWriteFileSync } from '../shared/atomic-write'
3
4
  import { join, dirname } from 'node:path'
4
5
  import { MANAGED_RULES } from './crsi-managed-rules'
5
6
  import { miphamHome } from './paths.ts'
@@ -160,7 +161,7 @@ export class ExperienceRuleEngine {
160
161
  const nonBuiltin = this.rules.filter((r) => r.source !== 'builtin' && r.source !== 'managed')
161
162
  const dir = dirname(this.storePath)
162
163
  mkdirSync(dir, { recursive: true })
163
- writeFileSync(this.storePath, JSON.stringify(nonBuiltin, null, 2), 'utf-8')
164
+ atomicWriteFileSync(this.storePath, JSON.stringify(nonBuiltin, null, 2), { mode: 0o644 })
164
165
  }
165
166
 
166
167
  /** Load persisted runtime rules from disk. Rejects rules whose IDs conflict with builtin/managed. */