@miphamai/cli 0.85.0 → 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.
- package/package.json +1 -1
- package/src/agent/agent-experience.ts +3 -2
- package/src/agent/background-registry.ts +7 -3
- package/src/agent/cross-session/discovery.ts +5 -12
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +3 -2
- package/src/agent/sub-agent.ts +44 -4
- package/src/agent-view/dashboard.tsx +22 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +46 -35
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/crsi-producer.ts +4 -2
- package/src/core/crsi-sandbox.ts +2 -1
- package/src/core/dream-engine.ts +5 -11
- package/src/core/engine.ts +9 -4
- package/src/core/error-signature-db.ts +3 -2
- package/src/core/eval-harness.ts +3 -3
- package/src/core/hooks-executor.ts +60 -2
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-rules.ts +99 -5
- package/src/core/permission.ts +6 -1
- package/src/core/rule-engine.ts +3 -2
- package/src/core/session-log.ts +48 -11
- package/src/core/session-store.ts +64 -44
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -3
- package/src/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +6 -0
- package/src/mcp/oauth.ts +47 -5
- package/src/mcp/token-store.ts +10 -11
- package/src/providers/openai-compat.ts +11 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/regular-file.ts +63 -0
- package/src/shared/sanitize.ts +14 -2
- package/src/shared/update.ts +45 -1
- package/src/tools/agent/memory.ts +4 -2
- package/src/tools/agent/workflow.ts +6 -3
- package/src/tools/exec/task.ts +82 -30
- package/src/tools/scheduling/cron.ts +3 -9
- package/src/ui/app.tsx +48 -13
- package/src/ui/commands.ts +5 -2
- package/src/ui/ctrl-c-confirm.ts +63 -0
- 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 {
|
|
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
|
-
|
|
17
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
542
|
+
// FIFO 路径上「写不进去」与下面的 catch 同级:ledger 非关键,但**不挂**。
|
|
543
|
+
appendRegularFileSync(proseLedgerFile(), JSON.stringify(record) + '\n')
|
|
542
544
|
} catch {
|
|
543
545
|
// ledger 非关键,失败不影响提议本身
|
|
544
546
|
}
|
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -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
|
-
|
|
646
|
+
atomicWriteFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), { mode: 0o644 })
|
|
646
647
|
} catch {
|
|
647
648
|
// Best-effort persistence
|
|
648
649
|
}
|
package/src/core/dream-engine.ts
CHANGED
|
@@ -25,15 +25,8 @@
|
|
|
25
25
|
* → Write consolidated memory + generate DreamReport
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
529
|
+
atomicWriteFileSync(this.dreamLog, JSON.stringify(log, null, 2), { mode: 0o644 })
|
|
536
530
|
} catch {
|
|
537
531
|
// Best-effort persistence
|
|
538
532
|
}
|
package/src/core/engine.ts
CHANGED
|
@@ -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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
package/src/core/eval-harness.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
import { tmpdir } from 'node:os'
|
|
16
|
-
import { mkdirSync,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
712
|
+
return matchPathRule(path, subPattern!, segmentMode)
|
|
619
713
|
}
|
|
620
714
|
|
|
621
715
|
return false
|
package/src/core/permission.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/core/rule-engine.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExperienceRule } from '../agent/experience-rules.js'
|
|
2
|
-
import { mkdirSync,
|
|
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
|
-
|
|
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. */
|
package/src/core/session-log.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
252
|
+
// 快照替换:整份投影由它重建 ⇒ 元素形状与 message 事件同罪
|
|
253
|
+
return Array.isArray(ev.messages) && ev.messages.every(isValidMessage)
|
|
217
254
|
default:
|
|
218
255
|
return true
|
|
219
256
|
}
|