@miphamai/cli 0.85.3 → 0.85.5
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-view/agents-standalone.tsx +42 -0
- package/src/commands/project.ts +75 -24
- package/src/core/context.ts +56 -5
- package/src/core/engine.ts +95 -34
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/permission-classifier.ts +21 -3
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +5 -2
- package/src/i18n-core/locales/zh-CN.json +5 -2
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +36 -15
- package/src/providers/fetch-utils.ts +53 -5
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +8 -0
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +1 -1
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { McpClient } from '../mcp/client'
|
|
3
|
+
import type { ToolCallResult } from '../mcp/types'
|
|
2
4
|
import type { HookConfig, HookContext, HookResult } from '../shared/index.ts'
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -7,13 +9,17 @@ import type { HookConfig, HookContext, HookResult } from '../shared/index.ts'
|
|
|
7
9
|
* Supported types:
|
|
8
10
|
* - command: Execute a shell command. Exit code 0 = allow, 2 = block with stderr as reason.
|
|
9
11
|
* - http: POST to a URL, response body becomes additionalContext.
|
|
10
|
-
* - mcp_tool: Call
|
|
12
|
+
* - mcp_tool: Call the MCP tool the hook names; its answer is read as the hook's.
|
|
11
13
|
* - code: No-op (handled inline by the handler function directly).
|
|
12
14
|
*/
|
|
13
|
-
export async function executeHook(
|
|
15
|
+
export async function executeHook(
|
|
16
|
+
cfg: HookConfig,
|
|
17
|
+
ctx: HookContext,
|
|
18
|
+
source?: string,
|
|
19
|
+
): Promise<HookResult> {
|
|
14
20
|
switch (cfg.type) {
|
|
15
21
|
case 'command':
|
|
16
|
-
return executeCommand(cfg, ctx)
|
|
22
|
+
return executeCommand(cfg, ctx, source)
|
|
17
23
|
case 'http':
|
|
18
24
|
return executeHttp(cfg, ctx)
|
|
19
25
|
case 'mcp_tool':
|
|
@@ -155,7 +161,26 @@ function spawnFailureCause(
|
|
|
155
161
|
return null
|
|
156
162
|
}
|
|
157
163
|
|
|
158
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The handle a failure message points the operator at: the command, and — when the
|
|
166
|
+
* hook came from a plugin rather than from the operator's own settings — who
|
|
167
|
+
* declared it.
|
|
168
|
+
*
|
|
169
|
+
* The command alone does not answer "which plugin do I look at". A plugin hook is
|
|
170
|
+
* typically `sh`, `node`, or a path under the plugin's root; none of those is a
|
|
171
|
+
* name the operator can search for. Two arguments rather than a pre-joined string
|
|
172
|
+
* because the parentheses and the `from` clause have to stay one decision: a
|
|
173
|
+
* caller that built half the label would be free to print `from "undefined"`.
|
|
174
|
+
*/
|
|
175
|
+
function failingLabel(command: string | undefined, source?: string): string {
|
|
176
|
+
return `(${command})${source ? ` from "${source}"` : ''}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function executeCommand(
|
|
180
|
+
cfg: HookConfig,
|
|
181
|
+
ctx: HookContext,
|
|
182
|
+
source?: string,
|
|
183
|
+
): Promise<HookResult> {
|
|
159
184
|
if (!cfg.command) return { allowed: true }
|
|
160
185
|
|
|
161
186
|
try {
|
|
@@ -220,14 +245,14 @@ async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookRe
|
|
|
220
245
|
if (failure) {
|
|
221
246
|
return {
|
|
222
247
|
allowed: true,
|
|
223
|
-
additionalContext: `Hook error
|
|
248
|
+
additionalContext: `Hook error ${failingLabel(cfg.command, source)}: ${failure}`,
|
|
224
249
|
}
|
|
225
250
|
}
|
|
226
251
|
|
|
227
252
|
// Other non-zero exit: don't block, log the error as context
|
|
228
253
|
return {
|
|
229
254
|
allowed: true,
|
|
230
|
-
additionalContext: `Hook warning
|
|
255
|
+
additionalContext: `Hook warning ${failingLabel(cfg.command, source)}: ${stderr.trim()}`,
|
|
231
256
|
}
|
|
232
257
|
} catch (err) {
|
|
233
258
|
// Only reached when `spawnSync` itself throws — masking-policy load, env
|
|
@@ -237,7 +262,7 @@ async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookRe
|
|
|
237
262
|
|
|
238
263
|
return {
|
|
239
264
|
allowed: true,
|
|
240
|
-
additionalContext: `Hook error
|
|
265
|
+
additionalContext: `Hook error ${failingLabel(cfg.command, source)}: ${message}`,
|
|
241
266
|
}
|
|
242
267
|
}
|
|
243
268
|
}
|
|
@@ -282,8 +307,60 @@ async function executeHttp(cfg: HookConfig, ctx: HookContext): Promise<HookResul
|
|
|
282
307
|
}
|
|
283
308
|
}
|
|
284
309
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
310
|
+
/**
|
|
311
|
+
* An `mcp_tool` hook: call the tool the hook names, and read its answer as the
|
|
312
|
+
* hook's own.
|
|
313
|
+
*
|
|
314
|
+
* The answer is read by the same contract a command hook's stdout follows — a
|
|
315
|
+
* structured decision decides, plain prose is context — so a tool that guards a
|
|
316
|
+
* tool call can block it the way a script would. An `isError` result is *not* a
|
|
317
|
+
* decision: it means the call did not speak, and an unreachable server reports
|
|
318
|
+
* the same way, so its message is reported rather than read as a verdict.
|
|
319
|
+
*/
|
|
320
|
+
async function executeMcpTool(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
|
|
321
|
+
if (!cfg.mcpServer || !cfg.mcpTool) return { allowed: true }
|
|
322
|
+
|
|
323
|
+
const client = McpClient.getInstance()
|
|
324
|
+
|
|
325
|
+
// Startup connects servers without blocking; this hook can arrive first.
|
|
326
|
+
if (!(await client.waitUntilReady(cfg.mcpServer))) {
|
|
327
|
+
return {
|
|
328
|
+
allowed: true,
|
|
329
|
+
additionalContext: `MCP hook (${cfg.mcpServer}/${cfg.mcpTool}): server "${cfg.mcpServer}" was still connecting — the tool was not called.`,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const result = await client.callTool(cfg.mcpServer, cfg.mcpTool, {
|
|
334
|
+
event: ctx.event,
|
|
335
|
+
toolName: ctx.toolName,
|
|
336
|
+
toolInput: ctx.toolInput,
|
|
337
|
+
sessionId: ctx.sessionId,
|
|
338
|
+
})
|
|
339
|
+
const body = mcpResultText(result)
|
|
340
|
+
|
|
341
|
+
if (result.isError) {
|
|
342
|
+
return {
|
|
343
|
+
allowed: true,
|
|
344
|
+
additionalContext: `MCP hook error (${cfg.mcpServer}/${cfg.mcpTool}): ${body.slice(0, 2000)}`,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const parsed = parseHookStdout(body, ctx)
|
|
349
|
+
const decided =
|
|
350
|
+
!parsed.allowed ||
|
|
351
|
+
parsed.additionalContext !== undefined ||
|
|
352
|
+
parsed.permissionDecision !== undefined ||
|
|
353
|
+
parsed.modifiedInput !== undefined
|
|
354
|
+
|
|
355
|
+
// Nothing in the hook contract matched, so the tool answered in prose: that
|
|
356
|
+
// answer is the context this hook contributes, not a silent no-op.
|
|
357
|
+
return decided ? parsed : { allowed: true, additionalContext: body.slice(0, 2000) || undefined }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The text an MCP tool call returned; non-text parts carry no message for a hook. */
|
|
361
|
+
function mcpResultText(result: ToolCallResult): string {
|
|
362
|
+
return result.content
|
|
363
|
+
.map((part) => part.text ?? '')
|
|
364
|
+
.filter(Boolean)
|
|
365
|
+
.join('\n')
|
|
289
366
|
}
|
package/src/core/hooks.ts
CHANGED
|
@@ -70,6 +70,18 @@ export class HookEngine {
|
|
|
70
70
|
)
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Remove every hook a given source declared, and nothing else.
|
|
75
|
+
*
|
|
76
|
+
* `unregister(event)` matches on the event alone, so a caller that wanted to undo
|
|
77
|
+
* its own registrations took down every hook on those events — the operator's own
|
|
78
|
+
* from settings, and other plugins'. Scoping by source is the only removal that
|
|
79
|
+
* answers the question the caller is actually asking.
|
|
80
|
+
*/
|
|
81
|
+
unregisterSource(source: string): void {
|
|
82
|
+
this.hooks = this.hooks.filter((h) => h.source !== source)
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
// ── Existing event executors ──
|
|
74
86
|
|
|
75
87
|
async executePreToolUse(
|
|
@@ -217,9 +229,21 @@ export class HookEngine {
|
|
|
217
229
|
|
|
218
230
|
// ── Health & Resilience ──
|
|
219
231
|
|
|
220
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* Get a hook health key for tracking.
|
|
234
|
+
*
|
|
235
|
+
* Health is per hook, and the key is what says which hook. It was the event (plus
|
|
236
|
+
* the tool name), which is a *class* of hooks rather than one of them: two hooks
|
|
237
|
+
* on the same event shared one failure counter and one disabled flag, so five
|
|
238
|
+
* failures from a plugin's broken hook could auto-disable an unrelated hook that
|
|
239
|
+
* had never failed. The source is what makes the key name one hook.
|
|
240
|
+
*
|
|
241
|
+
* A hook with no source keeps the string it has always had — `/hooks enable`
|
|
242
|
+
* takes these keys, and a key for a hook that has no plugin must not renumber.
|
|
243
|
+
*/
|
|
221
244
|
private healthKey(hook: HookDefinition): string {
|
|
222
|
-
|
|
245
|
+
const base = hook.toolName ? `${hook.event}:${hook.toolName}` : hook.event
|
|
246
|
+
return hook.source ? `${hook.source}:${base}` : base
|
|
223
247
|
}
|
|
224
248
|
|
|
225
249
|
/** Check if a hook should be skipped due to repeated failures. */
|
package/src/core/instructions.ts
CHANGED
|
@@ -156,12 +156,86 @@ export function buildPermissionBlock(mode: string): string {
|
|
|
156
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
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Level label used in each prompt part's provenance comment. */
|
|
160
|
+
const LEVEL_LABELS: Record<string, string> = {
|
|
161
|
+
group: 'Group Policy',
|
|
162
|
+
company: 'Company Policy',
|
|
163
|
+
project: 'Project Rules',
|
|
164
|
+
directory: 'Directory Rules',
|
|
165
|
+
user: 'User Preferences',
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The text one loaded file contributes to the system prompt — `prompt-exclude`
|
|
170
|
+
* sections stripped, `privacy: private` files omitted (`null`).
|
|
171
|
+
*
|
|
172
|
+
* Single source for the prompt **and** the size report. A report that measured
|
|
173
|
+
* the file on disk instead would overcount exactly the files this repository
|
|
174
|
+
* writes (its own `prompt-exclude` hides tens of thousands of characters), and
|
|
175
|
+
* the two numbers would drift apart with nothing saying which one is sent.
|
|
176
|
+
*/
|
|
177
|
+
function instructionPartText(inst: InstructionFile): string | null {
|
|
178
|
+
if (inst.privacy === 'private') return null
|
|
179
|
+
const content = stripSections(
|
|
180
|
+
inst.content,
|
|
181
|
+
parsePromptExclude(inst.frontmatter['prompt-exclude']),
|
|
182
|
+
)
|
|
183
|
+
return `<!-- ${LEVEL_LABELS[inst.level] || inst.level} (${inst.path}) -->\n${content}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One file's share of the instruction payload. */
|
|
187
|
+
export interface InstructionSize {
|
|
188
|
+
path: string
|
|
189
|
+
chars: number
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface InstructionSizeReport {
|
|
193
|
+
totalChars: number
|
|
194
|
+
/** Descending by size — the largest contributor first. */
|
|
195
|
+
files: InstructionSize[]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Characters of file-derived instruction text a session sends with **every**
|
|
200
|
+
* request, before the conversation starts. 40,000 is the budget this
|
|
201
|
+
* organisation already writes a single governance file against (the parent
|
|
202
|
+
* `CLAUDE.md`), so the notice fires when everything loaded together has grown
|
|
203
|
+
* past one such file.
|
|
204
|
+
*/
|
|
205
|
+
export const INSTRUCTION_BUDGET_CHARS = 40_000
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The startup notice, or `null` while the payload is within budget.
|
|
209
|
+
*
|
|
210
|
+
* The **total** is the point: no file has to be large for the instruction
|
|
211
|
+
* payload to crowd out the work, so a per-file check cannot see a dozen
|
|
212
|
+
* mid-sized rule files and a lessons block adding up. Naming the largest few
|
|
213
|
+
* is what makes the number actionable.
|
|
214
|
+
*/
|
|
215
|
+
export function formatInstructionSizeNotice(
|
|
216
|
+
report: InstructionSizeReport,
|
|
217
|
+
budget: number = INSTRUCTION_BUDGET_CHARS,
|
|
218
|
+
): string | null {
|
|
219
|
+
if (report.totalChars <= budget) return null
|
|
220
|
+
const num = (n: number) => n.toLocaleString('en-US')
|
|
221
|
+
const shown = report.files.slice(0, 3).map((f) => `${f.path} — ${num(f.chars)}`)
|
|
222
|
+
if (report.files.length > shown.length) shown.push(`+${report.files.length - shown.length} more`)
|
|
223
|
+
return (
|
|
224
|
+
`⚠ Instruction files total ${num(report.totalChars)} characters (budget ${num(budget)}), ` +
|
|
225
|
+
`sent with every request.\n` +
|
|
226
|
+
` Largest: ${shown.join(' · ')}\n` +
|
|
227
|
+
` Trim them, or move doc-only sections under a \`prompt-exclude\` frontmatter key.`
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
159
231
|
export class InstructionsLoader {
|
|
160
232
|
private instructions: InstructionFile[] = []
|
|
161
233
|
private crsiLessonSummaries: CrsiLessonSummary[] = []
|
|
234
|
+
private lessonsPath: string | null = null
|
|
162
235
|
|
|
163
236
|
loadAll(cwd: string): void {
|
|
164
237
|
this.instructions = []
|
|
238
|
+
this.lessonsPath = null
|
|
165
239
|
const root = gitRoot(cwd)
|
|
166
240
|
|
|
167
241
|
// Tier 1: 集团/公司策略(锚定仓库根,从任意子目录启动都正确;不读 AGENTS.md)
|
|
@@ -198,23 +272,12 @@ export class InstructionsLoader {
|
|
|
198
272
|
const parts: string[] = []
|
|
199
273
|
|
|
200
274
|
for (const inst of this.instructions) {
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const levelLabel: Record<string, string> = {
|
|
205
|
-
group: 'Group Policy',
|
|
206
|
-
company: 'Company Policy',
|
|
207
|
-
project: 'Project Rules',
|
|
208
|
-
directory: 'Directory Rules',
|
|
209
|
-
user: 'User Preferences',
|
|
210
|
-
}
|
|
211
|
-
// Strip doc-only sections declared via `prompt-exclude` frontmatter
|
|
275
|
+
// `instructionPartText` honors `privacy: private` (never sent) and strips
|
|
276
|
+
// doc-only sections declared via `prompt-exclude` frontmatter
|
|
212
277
|
// (changelog/roadmap/catalog are human-facing, not machine rules).
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
)
|
|
217
|
-
parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${content}`)
|
|
278
|
+
const text = instructionPartText(inst)
|
|
279
|
+
if (text === null) continue
|
|
280
|
+
parts.push(text)
|
|
218
281
|
}
|
|
219
282
|
|
|
220
283
|
// P2-2 的权限段**不在**这里 —— 见 `buildPermissionBlock` 与
|
|
@@ -371,8 +434,11 @@ Never omit it or present the work as purely human-authored.`)
|
|
|
371
434
|
|
|
372
435
|
/** 读 crsi-lessons.md(按仓库根定位)提取教训精华。读不到则返回空。 */
|
|
373
436
|
private loadCrsiLessons(root: string): CrsiLessonSummary[] {
|
|
437
|
+
const path = join(root, LESSONS_FILE)
|
|
374
438
|
try {
|
|
375
|
-
const content = readFileSync(
|
|
439
|
+
const content = readFileSync(path, 'utf-8')
|
|
440
|
+
// Remember where the recalled text came from — `sizeReport` names it.
|
|
441
|
+
this.lessonsPath = path
|
|
376
442
|
return extractCrsiLessonSummaries(content)
|
|
377
443
|
} catch {
|
|
378
444
|
return []
|
|
@@ -392,6 +458,28 @@ Never omit it or present the work as purely human-authored.`)
|
|
|
392
458
|
return [...this.instructions]
|
|
393
459
|
}
|
|
394
460
|
|
|
461
|
+
/**
|
|
462
|
+
* How much instruction text this loader puts in the system prompt, per file.
|
|
463
|
+
*
|
|
464
|
+
* Read through `instructionPartText` — the same projection `buildSystemPrompt`
|
|
465
|
+
* uses — so the report cannot describe something other than what is sent. The
|
|
466
|
+
* CRSI lessons block counts too: it is rendered from `crsi-lessons.md` and
|
|
467
|
+
* carried on every request like any other rule file.
|
|
468
|
+
*/
|
|
469
|
+
sizeReport(): InstructionSizeReport {
|
|
470
|
+
const files: InstructionSize[] = []
|
|
471
|
+
for (const inst of this.instructions) {
|
|
472
|
+
const text = instructionPartText(inst)
|
|
473
|
+
if (text !== null) files.push({ path: inst.path, chars: text.length })
|
|
474
|
+
}
|
|
475
|
+
if (this.lessonsPath) {
|
|
476
|
+
const lessons = buildCrsiLessonsBlock(this.crsiLessonSummaries)
|
|
477
|
+
if (lessons) files.push({ path: this.lessonsPath, chars: lessons.length })
|
|
478
|
+
}
|
|
479
|
+
files.sort((a, b) => b.chars - a.chars)
|
|
480
|
+
return { totalChars: files.reduce((n, f) => n + f.chars, 0), files }
|
|
481
|
+
}
|
|
482
|
+
|
|
395
483
|
private tryLoad(path: string, level: InstructionFile['level']): void {
|
|
396
484
|
if (!existsSync(path)) return
|
|
397
485
|
|
|
@@ -399,11 +399,21 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
399
399
|
|
|
400
400
|
let text = ''
|
|
401
401
|
let streamError: string | undefined
|
|
402
|
+
let truncated = false
|
|
402
403
|
try {
|
|
403
404
|
for await (const chunk of this.llm.chat({
|
|
404
405
|
model: this.config.resolveModel(),
|
|
405
406
|
messages: [{ role: 'user', content: prompt }],
|
|
406
|
-
maxTokens
|
|
407
|
+
// NO `maxTokens`. This cap is shared with the model's **thinking**, and the
|
|
408
|
+
// configured model may be a reasoning one: `reasoning_content` is billed
|
|
409
|
+
// against `max_tokens` but is not what the loop below accumulates, so a cap
|
|
410
|
+
// sized for the 17-character reply starves the reply itself. Measured
|
|
411
|
+
// 2026-09-24 against the configured `deepseek-v4-pro` on three realistic
|
|
412
|
+
// calls: ~880 chars of reasoning consumed the whole budget, `finish_reason`
|
|
413
|
+
// came back `length`, the visible answer was empty 3/3, and every one of
|
|
414
|
+
// those calls was held back as an unreadable reply — i.e. precisely the calls
|
|
415
|
+
// worth classifying are the ones that failed. The provider's own default
|
|
416
|
+
// (`req.maxTokens || declaredMaxOutput || 8192`) is the budget now.
|
|
407
417
|
temperature: 0,
|
|
408
418
|
signal: controller.signal,
|
|
409
419
|
})) {
|
|
@@ -411,6 +421,10 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
411
421
|
// An in-stream error would otherwise look exactly like an empty reply —
|
|
412
422
|
// and an empty reply is what a *denial* looks like. Name it instead.
|
|
413
423
|
else if (chunk.type === 'error') streamError = chunk.error ?? 'provider error'
|
|
424
|
+
// The provider sets this for `finish_reason: 'length'`. Reading it is the
|
|
425
|
+
// difference between "the reply was cut off at the cap" and "the reply was
|
|
426
|
+
// unreadable" — two very different things to hand a user.
|
|
427
|
+
else if (chunk.type === 'stop' && chunk.truncated) truncated = true
|
|
414
428
|
}
|
|
415
429
|
} catch (error) {
|
|
416
430
|
return {
|
|
@@ -435,10 +449,14 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
435
449
|
return { allow: false, rule: parsed.rule, reason: parsed.reason }
|
|
436
450
|
}
|
|
437
451
|
// Unreadable reply ⇒ held back, and said to be retryable — the model did not
|
|
438
|
-
// rule, so treating this as a policy refusal would be a lie.
|
|
452
|
+
// rule, so treating this as a policy refusal would be a lie. A reply the
|
|
453
|
+
// provider flagged as cut off gets named as such: "unreadable" sends the reader
|
|
454
|
+
// hunting for a malformed response when the cause was a token ceiling.
|
|
439
455
|
return {
|
|
440
456
|
allow: false,
|
|
441
|
-
reason:
|
|
457
|
+
reason: truncated
|
|
458
|
+
? `classifier reply was cut off at the output token cap before it ruled (${parsed.detail})`
|
|
459
|
+
: `classifier response unreadable: ${parsed.detail}`,
|
|
442
460
|
retryable: true,
|
|
443
461
|
}
|
|
444
462
|
}
|
|
@@ -362,7 +362,7 @@ function extractSubstitutions(command: string): string[] {
|
|
|
362
362
|
* REPORTTIME/REPORTMEMORY/DIRSTACKSIZE assignments immediately — and
|
|
363
363
|
* `bash -c 'rm -rf /'`. Over-matching is the safe direction for a deny rule.
|
|
364
364
|
*/
|
|
365
|
-
function flattenCommand(command: string, depth = 0): string[] {
|
|
365
|
+
export function flattenCommand(command: string, depth = 0): string[] {
|
|
366
366
|
const out: string[] = []
|
|
367
367
|
for (const seg of splitShellSegments(command)) {
|
|
368
368
|
out.push(seg)
|
package/src/core/permission.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
9
9
|
import { matchBashRule, compileRule } from './permission-rules'
|
|
10
|
+
import { detectDangerousRm } from '../security/dangerous-rm'
|
|
11
|
+
import type { DangerousRm } from '../security/dangerous-rm'
|
|
10
12
|
import {
|
|
11
13
|
loadPermissionConfig,
|
|
12
14
|
nextMode,
|
|
@@ -123,6 +125,7 @@ export type PermissionDenialReason =
|
|
|
123
125
|
| 'tool-default' // tool.permission === 'ask'
|
|
124
126
|
| 'system-default' // no rule, no tool permission → fallback ask
|
|
125
127
|
| 'classifier-deny' // `auto` mode's classifier ruled against the call
|
|
128
|
+
| 'dangerous-rm' // recursive rm whose target is not a path in the command text
|
|
126
129
|
|
|
127
130
|
/**
|
|
128
131
|
* Which denial reasons `auto` mode's classifier is allowed to rule on — an
|
|
@@ -138,6 +141,11 @@ export type PermissionDenialReason =
|
|
|
138
141
|
* `legacy-rule` is absent for the same reason as the rules: it is an explicit
|
|
139
142
|
* per-tool decision from `setRule()`. `classifier-deny` is absent because it is not
|
|
140
143
|
* a *static* reason at all — `explainDenial()` never returns it.
|
|
144
|
+
*
|
|
145
|
+
* `dangerous-rm` is absent because a classifier cannot be *asked* the question this
|
|
146
|
+
* reason answers. Every other reason here is "the mode was not sure, let a second
|
|
147
|
+
* opinion decide"; this one is "the command does not say what it will delete", and
|
|
148
|
+
* a second opinion reading the same command is reading the same missing text.
|
|
141
149
|
*/
|
|
142
150
|
const CLASSIFIABLE: ReadonlySet<PermissionDenialReason> = new Set<PermissionDenialReason>([
|
|
143
151
|
'mode-baseline',
|
|
@@ -463,6 +471,21 @@ export class PermissionSystem {
|
|
|
463
471
|
return 'ask' // absent tool → safest default
|
|
464
472
|
}
|
|
465
473
|
|
|
474
|
+
// ── A recursive `rm` whose target is not a path written in the command ──
|
|
475
|
+
// First, and deliberately **uncached**. Every other branch below reasons about
|
|
476
|
+
// the command's text; this is the one case where the text does not name what
|
|
477
|
+
// gets deleted, so it has to sit ahead of the allow rules and ahead of every
|
|
478
|
+
// mode baseline (`auto`, `bypassPermissions`) rather than inside them.
|
|
479
|
+
//
|
|
480
|
+
// Not cached because its answer depends on the environment opt-out, and the
|
|
481
|
+
// cache is keyed on tool+input alone. Caching here would mean an opt-out set
|
|
482
|
+
// before launch could never be observed being *un*set — the decision would
|
|
483
|
+
// outlive the input that produced it. Re-running the string check on each Bash
|
|
484
|
+
// call is cheaper than a cache that can contradict its own inputs.
|
|
485
|
+
if (this.dangerousRm(tool, input)) {
|
|
486
|
+
return 'ask'
|
|
487
|
+
}
|
|
488
|
+
|
|
466
489
|
// ── Cache lookup (P2): reuse decision for same tool+mode+input ──
|
|
467
490
|
const cacheKey = this.cacheKey(tool, input)
|
|
468
491
|
if (this.cacheMode === this.mode) {
|
|
@@ -551,7 +574,7 @@ export class PermissionSystem {
|
|
|
551
574
|
explainDenial(
|
|
552
575
|
tool: ToolDefinition,
|
|
553
576
|
input: Record<string, unknown>,
|
|
554
|
-
): { reason: PermissionDenialReason; rulePattern?: string } {
|
|
577
|
+
): { reason: PermissionDenialReason; rulePattern?: string; target?: string } {
|
|
555
578
|
for (const rule of this.denyRules) {
|
|
556
579
|
if (this.ruleMatches(rule, tool, input)) {
|
|
557
580
|
return { reason: 'deny-rule', rulePattern: rule.pattern }
|
|
@@ -562,6 +585,10 @@ export class PermissionSystem {
|
|
|
562
585
|
return { reason: 'ask-rule', rulePattern: rule.pattern }
|
|
563
586
|
}
|
|
564
587
|
}
|
|
588
|
+
const dangerous = this.dangerousRm(tool, input)
|
|
589
|
+
if (dangerous) {
|
|
590
|
+
return { reason: 'dangerous-rm', target: dangerous.target }
|
|
591
|
+
}
|
|
565
592
|
if (this.legacyRules.has(tool.name)) {
|
|
566
593
|
return { reason: 'legacy-rule' }
|
|
567
594
|
}
|
|
@@ -583,6 +610,28 @@ export class PermissionSystem {
|
|
|
583
610
|
return tool.name + '|' + JSON.stringify(input, Object.keys(input).sort())
|
|
584
611
|
}
|
|
585
612
|
|
|
613
|
+
/**
|
|
614
|
+
* A recursive `rm` whose target is not a path written in the command.
|
|
615
|
+
*
|
|
616
|
+
* Only the Bash tool: the question is about a *shell command line*, and a tool
|
|
617
|
+
* that merely happens to take a `command` parameter is not making a claim about
|
|
618
|
+
* what it will delete.
|
|
619
|
+
*
|
|
620
|
+
* The escape hatch is read from the **environment**, not from tool parameters.
|
|
621
|
+
* That is the difference between an operator switch and a model switch: a
|
|
622
|
+
* parameter the call can set is a guard the call can turn off, and
|
|
623
|
+
* `dangerouslyDisableSandbox` already shows how that ends. Read at call time
|
|
624
|
+
* rather than captured at construction so that a process which sets it before
|
|
625
|
+
* its first matching call gets the documented behaviour.
|
|
626
|
+
*/
|
|
627
|
+
private dangerousRm(tool: ToolDefinition, input: Record<string, unknown>): DangerousRm | null {
|
|
628
|
+
if (tool.name !== 'Bash') return null
|
|
629
|
+
if (process.env.MIPHAM_DISABLE_DANGEROUS_RM_PROMPT === '1') return null
|
|
630
|
+
const command = input.command
|
|
631
|
+
if (typeof command !== 'string') return null
|
|
632
|
+
return detectDangerousRm(command)
|
|
633
|
+
}
|
|
634
|
+
|
|
586
635
|
/**
|
|
587
636
|
* Resolve a call to a decision, consulting `auto` mode's classifier when — and
|
|
588
637
|
* only when — the static chain answered `'ask'` for a reason a classifier is
|
package/src/core/session-log.ts
CHANGED
|
@@ -118,6 +118,66 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
|
|
|
118
118
|
|
|
119
119
|
const LOG_DIR = miphamHome('sessions')
|
|
120
120
|
|
|
121
|
+
/** 补上的那条结果的正文:说明事情本身,并给出下一步,而不是只报一个状态。 */
|
|
122
|
+
function interruptedCallNotice(): string {
|
|
123
|
+
return (
|
|
124
|
+
`This tool call was in flight when the session ended, so its outcome is unknown — ` +
|
|
125
|
+
`the result was never recorded.\n` +
|
|
126
|
+
`Do not assume it succeeded or failed: check the actual state (read the files, ` +
|
|
127
|
+
`re-run the command) and re-issue the call if it did not take effect.`
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 恢复会话时收尾:给日志里**没有结果**的调用补一条「结果未知」的 `tool/result`
|
|
133
|
+
* 事件,返回补了几条。
|
|
134
|
+
*
|
|
135
|
+
* 为什么非补不可:助手消息里挂着 `tool_calls` 而没有任何结果回应,OpenAI / DeepSeek
|
|
136
|
+
* 会整条请求拒收,Anthropic 还要求 `tool_result` 紧跟在那条 `tool_use` 之后。于是
|
|
137
|
+
* 「这次的调用没收尾」在用户那里表现成「恢复之后说的第一句话就报协议错」。
|
|
138
|
+
*
|
|
139
|
+
* 这个形状**是从盘上读来的,不是引擎写出来的**:`engine.ts` 先落调用消息、紧接着
|
|
140
|
+
* 落结果(同一同步块),而这批事件只在退出时整份刷盘 —— 跑到一半被杀根本留不下那条
|
|
141
|
+
* 调用。够得着的是**读侧**:`save()` 逐行追加,`open()` 把读不动的行静默丢掉(半截
|
|
142
|
+
* JSON 过不了 `JSON.parse`),于是写盘写到一半被打断时,末尾那条结果被丢、调用留在
|
|
143
|
+
* 盘上。修在恢复这一步,是因为读侧的入口只有这一个(`ContextManager.restoreLog`)。
|
|
144
|
+
*
|
|
145
|
+
* 为什么补成**事件**而不是往投影里塞一条消息:本仓库的不变量是「模型看得见的必须已
|
|
146
|
+
* 记录」(`assertModelVisible`),凭空出现的消息正好违反它;`messageToEvents` /
|
|
147
|
+
* `deriveMessages` 的字节级互逆也不能被动过。补事件两边都成立 —— 模型**看得见那次
|
|
148
|
+
* 调用**(它本来就在历史里),也知道**结果未知**,于是它先去查证,而不是当成没发生过、
|
|
149
|
+
* 也不是猜成功或失败。
|
|
150
|
+
*
|
|
151
|
+
* 幂等:已经有结果的 id 不会再补第二条。
|
|
152
|
+
*/
|
|
153
|
+
export function closeInterruptedToolCalls(log: SessionLog): number {
|
|
154
|
+
const events = log.events()
|
|
155
|
+
const answered = new Set<string>()
|
|
156
|
+
for (const e of events) {
|
|
157
|
+
if (e.type === 'tool/result') answered.add(e.id)
|
|
158
|
+
else if (e.type === 'user/message' && Array.isArray(e.message.content)) {
|
|
159
|
+
// 结果也可能整条嵌在 user/message 里(多块消息不拆事件),一样算「已回答」。
|
|
160
|
+
for (const b of e.message.content) {
|
|
161
|
+
if (b.type === 'tool_result') answered.add(b.tool_use_id)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pending: string[] = []
|
|
167
|
+
for (const e of events) {
|
|
168
|
+
if (e.type === 'tool/call' && !answered.has(e.id)) pending.push(e.id)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const at = Date.now()
|
|
172
|
+
for (const id of pending) {
|
|
173
|
+
// 失败结果在投影里被读成 `error || content`(见 `deriveMessages`),两个字段同写;
|
|
174
|
+
// 这一段与 `deleted-cwd` 那次是同一个教训。
|
|
175
|
+
const content = interruptedCallNotice()
|
|
176
|
+
log.append({ type: 'tool/result', at, id, result: { success: false, content, error: content } })
|
|
177
|
+
}
|
|
178
|
+
return pending.length
|
|
179
|
+
}
|
|
180
|
+
|
|
121
181
|
/** 一次性告警:日志路径不是普通文件(写不进去),每个进程只说一句。 */
|
|
122
182
|
let warnedNotAppendable = false
|
|
123
183
|
|
package/src/daemon/index.ts
CHANGED
|
@@ -13,7 +13,6 @@ import type { Server } from 'bun'
|
|
|
13
13
|
import { DaemonDatabase } from './database'
|
|
14
14
|
import { SessionManager } from './session-manager'
|
|
15
15
|
import { AgentManager } from './agent-manager'
|
|
16
|
-
import { MessageBus } from './message-bus'
|
|
17
16
|
import { GoalManager } from './goal-manager'
|
|
18
17
|
import { ScheduleManager } from './schedule-manager'
|
|
19
18
|
import { createServer } from './server'
|
|
@@ -118,7 +117,7 @@ export function getPort(): number {
|
|
|
118
117
|
* 1. Ensures ~/.mipham exists (mode 0o700)
|
|
119
118
|
* 2. Loads or creates the auth token
|
|
120
119
|
* 3. Initializes the SQLite database and runs JSONL migration on first start
|
|
121
|
-
* 4. Creates a SessionManager
|
|
120
|
+
* 4. Creates a SessionManager and AgentManager
|
|
122
121
|
* 5. Starts the HTTP server on an available port
|
|
123
122
|
* 6. Writes PID and port files to disk
|
|
124
123
|
*
|
|
@@ -152,9 +151,8 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
152
151
|
const pool = new WorkerPool(db)
|
|
153
152
|
activePool = pool
|
|
154
153
|
|
|
155
|
-
// Create agent manager
|
|
154
|
+
// Create agent manager (Phase 3)
|
|
156
155
|
const agentManager = new AgentManager(db)
|
|
157
|
-
const messageBus = new MessageBus()
|
|
158
156
|
|
|
159
157
|
// Create goal manager and schedule manager (Phase 4)
|
|
160
158
|
const goalManager = new GoalManager(db)
|
|
@@ -236,7 +234,6 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
236
234
|
port,
|
|
237
235
|
hostname,
|
|
238
236
|
agentManager,
|
|
239
|
-
messageBus,
|
|
240
237
|
goalManager,
|
|
241
238
|
scheduleManager,
|
|
242
239
|
rateLimiter,
|