@miphamai/cli 0.85.1 → 0.85.2

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/agent/agent-experience.ts +3 -2
  3. package/src/agent/background-registry.ts +7 -3
  4. package/src/agent/cross-session/discovery.ts +5 -12
  5. package/src/agent/cross-session/file-inbox.ts +2 -2
  6. package/src/agent/effectiveness-tracker.ts +3 -2
  7. package/src/agent/sub-agent.ts +44 -4
  8. package/src/agent-view/dashboard.tsx +22 -0
  9. package/src/commands/autoloop-journal.ts +6 -5
  10. package/src/commands/environment.ts +11 -8
  11. package/src/config/credential-crypto.ts +13 -1
  12. package/src/config/keys-manager.ts +7 -4
  13. package/src/config/loader.ts +46 -35
  14. package/src/config/preferences.ts +6 -3
  15. package/src/core/constitution-loader.ts +3 -2
  16. package/src/core/crsi-producer.ts +4 -2
  17. package/src/core/crsi-sandbox.ts +2 -1
  18. package/src/core/dream-engine.ts +5 -11
  19. package/src/core/engine.ts +9 -4
  20. package/src/core/error-signature-db.ts +3 -2
  21. package/src/core/eval-harness.ts +3 -3
  22. package/src/core/hooks-executor.ts +60 -2
  23. package/src/core/memory/memory-manager.ts +10 -6
  24. package/src/core/permission-audit.ts +13 -6
  25. package/src/core/permission-rules.ts +99 -5
  26. package/src/core/permission.ts +6 -1
  27. package/src/core/rule-engine.ts +3 -2
  28. package/src/core/session-log.ts +48 -11
  29. package/src/core/session-store.ts +64 -44
  30. package/src/daemon/auth.ts +4 -3
  31. package/src/daemon/index.ts +4 -3
  32. package/src/i18n-core/locales/en-US.json +1 -0
  33. package/src/i18n-core/locales/zh-CN.json +1 -0
  34. package/src/index.tsx +6 -0
  35. package/src/mcp/oauth.ts +47 -5
  36. package/src/mcp/token-store.ts +10 -11
  37. package/src/providers/openai-compat.ts +11 -0
  38. package/src/shared/package-info.ts +1 -1
  39. package/src/shared/regular-file.ts +63 -0
  40. package/src/shared/sanitize.ts +14 -2
  41. package/src/tools/agent/memory.ts +4 -2
  42. package/src/tools/agent/workflow.ts +6 -3
  43. package/src/tools/exec/task.ts +82 -30
  44. package/src/tools/scheduling/cron.ts +3 -9
  45. package/src/ui/app.tsx +48 -13
  46. package/src/ui/commands.ts +5 -2
  47. package/src/ui/ctrl-c-confirm.ts +63 -0
  48. package/src/workflow/journal.ts +80 -26
@@ -5,16 +5,19 @@
5
5
  * NOT for config.yml settings — those belong in the YAML config system.
6
6
  * NOT for secrets — this file is plain JSON, not encrypted.
7
7
  */
8
- import { readFileSync, existsSync, mkdirSync } from 'node:fs'
8
+ import { existsSync, mkdirSync } from 'node:fs'
9
9
  import { atomicWriteFileSync } from '../shared/atomic-write'
10
+ import { readRegularFileSync } from '../shared/regular-file'
10
11
  import { miphamHome } from '../core/paths.ts'
11
12
 
12
13
  const PREFS_PATH = miphamHome('preferences.json')
13
14
 
14
15
  function readPrefs(): Record<string, string> {
15
16
  try {
16
- if (!existsSync(PREFS_PATH)) return {}
17
- const raw = readFileSync(PREFS_PATH, 'utf-8')
17
+ // 类型闸在读取之前 —— FIFO 上的 `readFileSync` 会一直等写者(见
18
+ // shared/regular-file.ts)。
19
+ const raw = readRegularFileSync(PREFS_PATH)
20
+ if (raw === null) return {}
18
21
  const parsed: unknown = JSON.parse(raw)
19
22
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
20
23
  return parsed as Record<string, string>
@@ -13,7 +13,8 @@
13
13
  * Format: YAML with schema validation
14
14
  */
15
15
 
16
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
16
+ import { readFileSync, mkdirSync, existsSync } from 'node:fs'
17
+ import { atomicWriteFileSync } from '../shared/atomic-write'
17
18
  import { miphamHome } from './paths.ts'
18
19
  import alignmentVocabulary from './alignment-vocabulary.json' with { type: 'json' }
19
20
 
@@ -103,7 +104,7 @@ export class ConstitutionLoader {
103
104
  try {
104
105
  const dir = miphamHome()
105
106
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
106
- writeFileSync(this.path, this.serializeToYaml(DEFAULT_CONSTITUTION), 'utf-8')
107
+ atomicWriteFileSync(this.path, this.serializeToYaml(DEFAULT_CONSTITUTION), { mode: 0o644 })
107
108
  } catch {
108
109
  // Best-effort — default constitution works in-memory
109
110
  }
@@ -13,8 +13,9 @@
13
13
  import type { CrsiInsight } from './auto-memory'
14
14
  import type { MetaRule } from './meta-rule-engine'
15
15
  import type { Llm } from '../providers/llm'
16
- import { readdirSync, appendFileSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
16
+ import { readdirSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
+ import { appendRegularFileSync } from '../shared/regular-file'
18
19
  import { miphamHome } from './paths.ts'
19
20
 
20
21
  /** 教训文件(相对仓库根)。预建,沙箱只能改已存在文件。 */
@@ -538,7 +539,8 @@ export function hasProposedProse(id: string): boolean {
538
539
  export function appendProseProposal(record: ProseProposalRecord): void {
539
540
  try {
540
541
  mkdirSync(miphamHome('crsi'), { recursive: true })
541
- appendFileSync(proseLedgerFile(), JSON.stringify(record) + '\n', 'utf-8')
542
+ // FIFO 路径上「写不进去」与下面的 catch 同级:ledger 非关键,但**不挂**。
543
+ appendRegularFileSync(proseLedgerFile(), JSON.stringify(record) + '\n')
542
544
  } catch {
543
545
  // ledger 非关键,失败不影响提议本身
544
546
  }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { execSync } from 'node:child_process'
18
18
  import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
+ import { atomicWriteFileSync } from '../shared/atomic-write'
19
20
  import { join, resolve, sep, posix } from 'node:path'
20
21
  import { tmpdir } from 'node:os'
21
22
  import { randomUUID } from 'node:crypto'
@@ -642,7 +643,7 @@ export class CrsiSandbox {
642
643
  // Persist to ~/.mipham/crsi-sandbox/
643
644
  const reportPath = join(REPORT_DIR, `${this.sessionReport.sessionId}.json`)
644
645
  try {
645
- writeFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), 'utf-8')
646
+ atomicWriteFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), { mode: 0o644 })
646
647
  } catch {
647
648
  // Best-effort persistence
648
649
  }
@@ -25,15 +25,8 @@
25
25
  * → Write consolidated memory + generate DreamReport
26
26
  */
27
27
 
28
- import {
29
- readdirSync,
30
- readFileSync,
31
- writeFileSync,
32
- unlinkSync,
33
- existsSync,
34
- mkdirSync,
35
- statSync,
36
- } from 'node:fs'
28
+ import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync, statSync } from 'node:fs'
29
+ import { atomicWriteFileSync } from '../shared/atomic-write'
37
30
  import { join } from 'node:path'
38
31
  import { miphamHome } from './paths.ts'
39
32
 
@@ -320,7 +313,8 @@ export class DreamEngine {
320
313
  try {
321
314
  const links = secondary.map((s) => `- See also: [[${s.name}]]`).join('\n')
322
315
  const updated = primary.content + `\n\n## Related\n${links}`
323
- writeFileSync(primary.path, updated, 'utf-8')
316
+ // 记忆正文:与 memory-manager 同权限语义(用户可读可编辑的内容)。
317
+ atomicWriteFileSync(primary.path, updated, { mode: 0o644 })
324
318
 
325
319
  for (const s of secondary) {
326
320
  try {
@@ -532,7 +526,7 @@ export class DreamEngine {
532
526
  log.unshift({ timestamp, actions })
533
527
  // Keep last 10 dream cycles
534
528
  if (log.length > 10) log = log.slice(0, 10)
535
- writeFileSync(this.dreamLog, JSON.stringify(log, null, 2), 'utf-8')
529
+ atomicWriteFileSync(this.dreamLog, JSON.stringify(log, null, 2), { mode: 0o644 })
536
530
  } catch {
537
531
  // Best-effort persistence
538
532
  }
@@ -488,10 +488,15 @@ export class QueryEngine {
488
488
  try {
489
489
  for await (const chunk of this.llmChat({
490
490
  model: this.registry.getActiveModel(),
491
- messages: [
492
- { role: 'system', content: summaryPrompt },
493
- { role: 'user', content: text },
494
- ],
491
+ // The instruction goes in `systemPrompt`, **not** as a `system` message
492
+ // in the array. A system *entry* is a header, and the providers do not
493
+ // agree on one that arrives inside `messages`: anthropic drops every
494
+ // system entry (its protocol wants the top-level param, anthropic.ts),
495
+ // so the array form reached the OpenAI-compatible providers and no one
496
+ // else — the summarizer's instruction was missing for anthropic users
497
+ // while it worked here. `systemPrompt` is the one spelling both read.
498
+ systemPrompt: summaryPrompt,
499
+ messages: [{ role: 'user', content: text }],
495
500
  maxTokens: 2000,
496
501
  })) {
497
502
  if (chunk.type === 'text' && chunk.content) {
@@ -14,7 +14,8 @@
14
14
  * PreFlightChecker.check() → ErrorSignatureDB.match()
15
15
  */
16
16
 
17
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
17
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs'
18
+ import { atomicWriteFileSync } from '../shared/atomic-write'
18
19
  import { join } from 'node:path'
19
20
  import { randomUUID } from 'node:crypto'
20
21
  import { miphamHome } from './paths.ts'
@@ -101,7 +102,7 @@ export class ErrorSignatureDB {
101
102
  try {
102
103
  mkdirSync(join(this.storePath, '..'), { recursive: true })
103
104
  const arr = Array.from(this.signatures.values())
104
- writeFileSync(this.storePath, JSON.stringify(arr, null, 2), 'utf-8')
105
+ atomicWriteFileSync(this.storePath, JSON.stringify(arr, null, 2), { mode: 0o644 })
105
106
  this.dirty = false
106
107
  } catch {
107
108
  // Best-effort persistence — never crash on write failure
@@ -13,7 +13,8 @@
13
13
 
14
14
  import { join } from 'node:path'
15
15
  import { tmpdir } from 'node:os'
16
- import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'node:fs'
16
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs'
17
+ import { appendRegularFileSync } from '../shared/regular-file'
17
18
  import { ExperienceRuleEngine } from './rule-engine'
18
19
  import { ConstitutionLoader, DEFAULT_CONSTITUTION } from './constitution-loader'
19
20
  import { ErrorSignatureDB } from './error-signature-db'
@@ -119,7 +120,7 @@ export function appendEvalScore(
119
120
  ): void {
120
121
  try {
121
122
  mkdirSync(miphamHome('crsi'), { recursive: true })
122
- appendFileSync(
123
+ appendRegularFileSync(
123
124
  SCORES_FILE,
124
125
  JSON.stringify({
125
126
  name,
@@ -131,7 +132,6 @@ export function appendEvalScore(
131
132
  // 读取侧跳过 —— 这是向后兼容的承重判据,别改成 `results: []`。
132
133
  ...(report.results ? { results: report.results.map(toContractResultRecord) } : {}),
133
134
  }) + '\n',
134
- 'utf-8',
135
135
  )
136
136
  } catch {
137
137
  // rewards 日志是非关键的——失败不影响评估本身
@@ -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}`,
@@ -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
@@ -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. */
@@ -1,8 +1,9 @@
1
- import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
1
+ import { mkdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { createHash } from 'node:crypto'
4
4
  import type { Message, ToolUseContent, ToolResultContent, ToolResult } from '../shared/types'
5
5
  import type { CheckerDecision } from './post-flight-checker'
6
+ import { appendRegularFileSync, readRegularFileSync } from '../shared/regular-file'
6
7
  import { miphamHome } from './paths.ts'
7
8
 
8
9
  export type SessionEvent =
@@ -117,6 +118,9 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
117
118
 
118
119
  const LOG_DIR = miphamHome('sessions')
119
120
 
121
+ /** 一次性告警:日志路径不是普通文件(写不进去),每个进程只说一句。 */
122
+ let warnedNotAppendable = false
123
+
120
124
  /** 将会话名消毒为安全文件名(与 SessionStore 共用;防路径穿越)。 */
121
125
  export function sanitizeSessionName(name: string): string {
122
126
  const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_')
@@ -147,22 +151,34 @@ export class SessionLog {
147
151
  // Session logs contain full conversation + tool results (possibly credentials):
148
152
  // restrict to owner-only (dir 0700, file 0600).
149
153
  mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 })
154
+ const path = join(LOG_DIR, `${sanitizeSessionName(this.name)}.jsonl`)
155
+ let wrote = 0
150
156
  for (const e of this.buf.slice(this.flushed)) {
151
- appendFileSync(
152
- join(LOG_DIR, `${sanitizeSessionName(this.name)}.jsonl`),
153
- JSON.stringify(e) + '\n',
154
- { encoding: 'utf-8', mode: 0o600 },
155
- )
157
+ // FIFO/socket 路径上「写不进去」而不是**干等**(`appendFileSync` 会打开目标写)。
158
+ // 写失败时**不推进** flushed:这些事件还算未落盘,路径恢复后下一次 save 会补上。
159
+ if (!appendRegularFileSync(path, JSON.stringify(e) + '\n', { mode: 0o600 })) {
160
+ if (!warnedNotAppendable) {
161
+ warnedNotAppendable = true
162
+ process.stderr.write(
163
+ `⚠ Mipham Code: ${path} is not a regular file — session log events were not written.\n`,
164
+ )
165
+ }
166
+ break
167
+ }
168
+ wrote++
156
169
  }
157
- this.flushed = this.buf.length
170
+ this.flushed += wrote
158
171
  }
159
172
 
160
173
  /** 从既有 JSONL 打开,逐行解析为事件(已落盘事件标记为已 flush)。 */
161
174
  static open(name: string): SessionLog {
162
175
  const log = new SessionLog(name)
163
176
  const path = join(LOG_DIR, `${sanitizeSessionName(name)}.jsonl`)
164
- if (!existsSync(path)) return log
165
- for (const line of readFileSync(path, 'utf-8').split('\n')) {
177
+ // 类型闸在读取之前:`SessionStore.load` 在快照读不出来时**回落到这里**,所以路径上
178
+ // 是个 FIFO 时不能在这里换个函数继续等 —— 读不动就当空日志(见 shared/regular-file.ts)。
179
+ const raw = readRegularFileSync(path)
180
+ if (raw === null) return log
181
+ for (const line of raw.split('\n')) {
166
182
  const trimmed = line.trim()
167
183
  if (!trimmed) continue
168
184
  try {
@@ -178,6 +194,23 @@ export class SessionLog {
178
194
  }
179
195
  }
180
196
 
197
+ /**
198
+ * 消息形状闸 —— 投影与**出网**两侧都会解引用的字段的最低门槛,不是 schema 校验。
199
+ *
200
+ * 从前这里只查「`message` 是个对象」,于是 `{"type":"user/message","message":{}}`
201
+ * 一路活到线上:投影照原样收下它,provider 侧读到 `content` 为 `undefined` 走
202
+ * 「非字符串即 `ContentBlock[]`」那条分支,`.filter` 当场 TypeError(实测)。
203
+ * `content` 数组里的块同理 —— provider 会 `b.type` 逐个读。
204
+ */
205
+ export function isValidMessage(m: unknown): boolean {
206
+ if (!m || typeof m !== 'object') return false
207
+ const msg = m as Record<string, unknown>
208
+ if (msg.role !== 'user' && msg.role !== 'assistant' && msg.role !== 'system') return false
209
+ const content = msg.content
210
+ if (typeof content === 'string') return true
211
+ return Array.isArray(content) && content.every((b) => !!b && typeof b === 'object')
212
+ }
213
+
181
214
  /**
182
215
  * 事件结构校验 —— 磁盘→内存的**唯一**入口(`open()`)用它挡掉坏行。
183
216
  *
@@ -187,6 +220,9 @@ export class SessionLog {
187
220
  * 却让投影抛 TypeError(rewrite 那条更狠:`out` 直接变 `undefined`,下一条就炸),
188
221
  * 而 `/resume` 那条链上没有 try 兜它。坏行来自手写/拼接/半截重排,不是本进程写的。
189
222
  *
223
+ * 带 message 的事件还要过 `isValidMessage`:投影只把它 `push` 进数组、自己不碰字段,
224
+ * 所以「投影不许崩」在这里**不够** —— 崩点在下游(provider 分支、UI 的 `msg.role`)。
225
+ *
190
226
  * 不校验 `session/start`、`assistant/chunk`、`checker/decision`:投影对它们无分支;
191
227
  * 未知类型一律放行(前向兼容 —— 认不出来不等于要销毁它)。
192
228
  */
@@ -197,7 +233,7 @@ function isValidEvent(e: unknown): e is SessionEvent {
197
233
  switch (ev.type) {
198
234
  case 'user/message':
199
235
  case 'assistant/message':
200
- return !!ev.message && typeof ev.message === 'object'
236
+ return isValidMessage(ev.message)
201
237
  case 'tool/call':
202
238
  return (
203
239
  typeof ev.id === 'string' &&
@@ -213,7 +249,8 @@ function isValidEvent(e: unknown): e is SessionEvent {
213
249
  case 'compaction/summary':
214
250
  return typeof ev.summary === 'string'
215
251
  case 'compaction/rewrite':
216
- return Array.isArray(ev.messages)
252
+ // 快照替换:整份投影由它重建 ⇒ 元素形状与 message 事件同罪
253
+ return Array.isArray(ev.messages) && ev.messages.every(isValidMessage)
217
254
  default:
218
255
  return true
219
256
  }