@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.
- package/bin/mipham.ts +42 -2
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +9 -2
- 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 +67 -6
- package/src/agent/types.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +46 -0
- package/src/agent-view/dashboard.tsx +106 -16
- package/src/agent-view/session-view.tsx +128 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/commands/project.ts +5 -3
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +144 -41
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/context.ts +35 -6
- 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/instructions.ts +69 -48
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-config.ts +28 -7
- 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/attach-protocol.ts +30 -3
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -3
- package/src/daemon/remote-engine.ts +173 -24
- package/src/daemon/server.ts +51 -1
- package/src/daemon/session-worker.ts +34 -1
- package/src/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +75 -17
- 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/arg-validation.ts +74 -2
- 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/skills/bundled-skills.ts +1 -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 +78 -14
- package/src/ui/commands.ts +73 -31
- package/src/ui/ctrl-c-confirm.ts +63 -0
- 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
|
|
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}`,
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
160
|
-
|
|
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
|
-
*
|
|
326
|
-
*
|
|
383
|
+
* Loader-shaped alias of {@link buildPermissionBlock}(模块级那个才是实现)。
|
|
384
|
+
* 保留它是因为**已有**的调用点都握着一个装载器:`index.tsx` 的接线行与
|
|
385
|
+
* `test/core/permission-prompt-live.test.ts` 的 `wire()`。它不含任何状态。
|
|
327
386
|
*/
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
*
|
|
120
|
-
*
|
|
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) =>
|
|
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. */
|