@miphamai/cli 0.81.8 → 0.81.9
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 +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +7 -1
- package/src/config/loader.ts +202 -63
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/engine.ts +7 -2
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +17 -2
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +5 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +44 -17
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +13 -2
- package/src/providers/anthropic.ts +48 -11
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +19 -1
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/exec/bash.ts +106 -6
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +76 -1
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +33 -3
- package/src/tools/index.ts +12 -4
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +160 -30
- package/src/workflow/primitives/agent.ts +4 -0
package/src/daemon/server.ts
CHANGED
|
@@ -101,6 +101,10 @@ export function buildDaemonPermission(
|
|
|
101
101
|
for (const msg of permission.getInvalidRules()) {
|
|
102
102
|
process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
|
|
103
103
|
}
|
|
104
|
+
// Sibling channel for malformed restrictions (P1) — same as the CLI entry.
|
|
105
|
+
for (const msg of permission.getInvalidRestrictions()) {
|
|
106
|
+
process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
|
|
107
|
+
}
|
|
104
108
|
return permission
|
|
105
109
|
}
|
|
106
110
|
|
|
@@ -140,6 +144,12 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
140
144
|
dingtalk,
|
|
141
145
|
} = config
|
|
142
146
|
|
|
147
|
+
// Mutable: `POST /api/v1/auth/rotate` replaces the live token. Destructuring
|
|
148
|
+
// `token` above yields a const, so the rotate route could only ever write the
|
|
149
|
+
// new token to disk — the running server kept accepting the old one and
|
|
150
|
+
// rejected the one it had just handed out.
|
|
151
|
+
let activeToken = token
|
|
152
|
+
|
|
143
153
|
const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
|
|
144
154
|
|
|
145
155
|
// Captured once: a later chdir must not move the boundary that callers'
|
|
@@ -362,6 +372,23 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
362
372
|
const server = Bun.serve<WsData>({
|
|
363
373
|
port,
|
|
364
374
|
hostname,
|
|
375
|
+
// The fetch handler below has no top-level try/catch, so anything it throws
|
|
376
|
+
// reaches Bun — and `development` is true unless NODE_ENV=production, which
|
|
377
|
+
// for a daemon bound to MIPHAM_BIND=0.0.0.0 means an unauthenticated peer
|
|
378
|
+
// gets Bun's development error overlay. Measured on bun 1.3.14: with the
|
|
379
|
+
// flag off the body is a 67,154-byte HTML application; with it on, 21 bytes
|
|
380
|
+
// of plain text.
|
|
381
|
+
//
|
|
382
|
+
// Not established, and so not claimed: that the overlay hands back the
|
|
383
|
+
// source. Re-requesting the failing URL with the overlay's own Accept
|
|
384
|
+
// header, guessing the on-disk path, and /bun:info all failed to return the
|
|
385
|
+
// thrown text in a local probe (every unmatched path returned the same
|
|
386
|
+
// shell). What is certain is the shape: a developer debugging UI, shipped
|
|
387
|
+
// with its own JS, served to whoever sent the request.
|
|
388
|
+
//
|
|
389
|
+
// Set explicitly rather than relying on NODE_ENV — how an exposed daemon
|
|
390
|
+
// answers an error must not depend on the user's environment.
|
|
391
|
+
development: false,
|
|
365
392
|
async fetch(req, server) {
|
|
366
393
|
// ── CORS preflight ──────────────────────────────
|
|
367
394
|
const corsResponse = corsMiddleware(req)
|
|
@@ -397,7 +424,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
397
424
|
}
|
|
398
425
|
|
|
399
426
|
// ── Auth check ──────────────────────────────────
|
|
400
|
-
const authError = authMiddleware(req,
|
|
427
|
+
const authError = authMiddleware(req, activeToken, server.requestIP(req)?.address)
|
|
401
428
|
if (authError) return addCorsHeaders(authError, req)
|
|
402
429
|
|
|
403
430
|
// Helper: create JSON response with CORS headers for external origins
|
|
@@ -691,6 +718,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
691
718
|
// ── Auth: rotate token ───────────────────────────
|
|
692
719
|
if (method === 'POST' && path === '/api/v1/auth/rotate') {
|
|
693
720
|
const newToken = rotateToken(tokenPath)
|
|
721
|
+
activeToken = newToken
|
|
694
722
|
return json({ ok: true, data: { token: newToken } })
|
|
695
723
|
}
|
|
696
724
|
|
|
@@ -311,27 +311,27 @@
|
|
|
311
311
|
},
|
|
312
312
|
"resume": {
|
|
313
313
|
"no_sessions": "No saved sessions.",
|
|
314
|
-
"restored": "─ Session
|
|
314
|
+
"restored": "─ Session History Loaded ─",
|
|
315
315
|
"not_found": "Session \"{name}\" not found.",
|
|
316
316
|
"saved_title": "─ Saved Sessions ─",
|
|
317
317
|
"found_title": "─ Session Found ─",
|
|
318
318
|
"not_found_title": "─ Session Not Found ─",
|
|
319
319
|
"load_failed": "─ Load Failed ─",
|
|
320
320
|
"restored_content": "Name: {name}\nMessages: {total} total{truncated}\nProvider: {provider} / {model}\nUpdated: {date}",
|
|
321
|
-
"restored_footer": "{loaded} of {total} messages loaded
|
|
322
|
-
"restored_full_footer": "{loaded} messages loaded.
|
|
321
|
+
"restored_footer": "{loaded} of {total} messages loaded into the view (older ones not shown).",
|
|
322
|
+
"restored_full_footer": "{loaded} messages loaded into the view. The model's context is unchanged — to continue this session, run: mipham --resume \"{name}\"",
|
|
323
323
|
"delete_usage": "Usage: /resume delete <session-name>\n\nDelete a saved session. Use /resume to list all sessions.",
|
|
324
324
|
"delete_confirmed": "✓ Session \"{name}\" deleted.",
|
|
325
325
|
"delete_not_found": "✗ Session \"{name}\" not found. Use /resume to list all sessions.",
|
|
326
326
|
"empty_footer": "Sessions are auto-saved to ~/.mipham/sessions/ when Mipham Code exits.\nStart a conversation — it will be saved automatically.",
|
|
327
327
|
"total_footer": "Total: {count} session(s) • Location: ~/.mipham/sessions/",
|
|
328
|
-
"resume_hint": "
|
|
329
|
-
"resume_last_hint": "
|
|
328
|
+
"resume_hint": "Session details: /resume <name>",
|
|
329
|
+
"resume_last_hint": "Load recent history: /resume last",
|
|
330
330
|
"delete_hint": "To delete a session: /resume delete <name>",
|
|
331
331
|
"cli_hint": "To resume from CLI: mipham --resume \"<name>\"",
|
|
332
|
-
"auto_save_hint": "Sessions are auto-saved on exit.
|
|
332
|
+
"auto_save_hint": "Sessions are auto-saved on exit. On restart only the most recent session's summary is injected as context — to reopen a session: mipham --resume \"<name>\"",
|
|
333
333
|
"not_found_content": "No session named \"{name}\".\n\nUse /resume without arguments to list all saved sessions.",
|
|
334
|
-
"found_content": "Name: {name}\nMessages: {messages}\nProvider: {provider} / {model}\nUpdated: {updated}\n\nTo
|
|
334
|
+
"found_content": "Name: {name}\nMessages: {messages}\nProvider: {provider} / {model}\nUpdated: {updated}\n\nTo continue this session, start a new process with:\n mipham --resume \"{target}\""
|
|
335
335
|
},
|
|
336
336
|
"help": { "title": "── Mipham Code Help ──" },
|
|
337
337
|
"lang": {
|
|
@@ -423,6 +423,7 @@
|
|
|
423
423
|
},
|
|
424
424
|
"upgrade": {
|
|
425
425
|
"uptodate": "── Upgrade Mipham Code ──\n\nCurrent version: v{current}\nLatest: v{latest}\n\n✓ Already up to date.\n\nTo check manually: https://www.npmjs.com/package/@miphamai/cli",
|
|
426
|
+
"check_failed": "── Upgrade Mipham Code ──\n\n✗ Could not reach the npm registry — unable to tell whether v{current} is the latest.\n Nothing was changed. Check your network, then retry: /upgrade",
|
|
426
427
|
"title": "── Upgrade Mipham Code ──",
|
|
427
428
|
"available": "Current version: v{current}\nLatest: v{latest}\n\n→ New version available! Updating...",
|
|
428
429
|
"updated": "✓ Updated to @miphamai/cli v{version}",
|
|
@@ -558,7 +559,7 @@
|
|
|
558
559
|
"stats": {
|
|
559
560
|
"title": "── Session Stats ──",
|
|
560
561
|
"messages": "Messages: {total} ({user} user, {assistant} AI, {system} system)",
|
|
561
|
-
"tokens": "Tokens: ~{tokens} /
|
|
562
|
+
"tokens": "Tokens: ~{tokens} / {max}",
|
|
562
563
|
"tools": "Tools available: {count}",
|
|
563
564
|
"provider": "Provider: {provider}",
|
|
564
565
|
"model": "Model: {model}",
|
|
@@ -585,6 +586,9 @@
|
|
|
585
586
|
"title": "── Hooks ──",
|
|
586
587
|
"no_hooks": "── Hooks ──\n\nNo hooks configured.\n\nConfigure hooks in .mipham/settings.json under the hooks section:\n PreToolUse — before each tool\n PostToolUse — after each tool\n Stop — on session end\n\nCommand hooks read JSON from stdin; exit 2 blocks (stderr as reason).\nUse /loop init to scaffold settings.json.",
|
|
587
588
|
"location": "Location: {path}",
|
|
589
|
+
"source_user": "user",
|
|
590
|
+
"source_project": "project",
|
|
591
|
+
"gated": "⚠️ workspace not trusted — will not run",
|
|
588
592
|
"found": "{count} hook(s) found.",
|
|
589
593
|
"error": "── Hooks ──\n\nCould not read hooks directory."
|
|
590
594
|
},
|
|
@@ -311,27 +311,27 @@
|
|
|
311
311
|
},
|
|
312
312
|
"resume": {
|
|
313
313
|
"no_sessions": "没有已保存的会话。",
|
|
314
|
-
"restored": "─
|
|
314
|
+
"restored": "─ 会话历史已载入 ─",
|
|
315
315
|
"not_found": "未找到会话 \"{name}\"。",
|
|
316
316
|
"saved_title": "─ 已保存的会话 ─",
|
|
317
317
|
"found_title": "─ 找到会话 ─",
|
|
318
318
|
"not_found_title": "─ 未找到会话 ─",
|
|
319
319
|
"load_failed": "─ 加载失败 ─",
|
|
320
320
|
"restored_content": "名称: {name}\n消息数: {total} 条{truncated}\n提供商: {provider} / {model}\n更新时间: {date}",
|
|
321
|
-
"restored_footer": "
|
|
322
|
-
"restored_full_footer": "
|
|
321
|
+
"restored_footer": "已载入视图 {loaded}/{total} 条消息(较早的未显示)。",
|
|
322
|
+
"restored_full_footer": "已载入视图 {loaded} 条消息;模型上下文未变 —— 要接着此会话继续,请运行: mipham --resume \"{name}\"",
|
|
323
323
|
"delete_usage": "用法: /resume delete <session-name>\n\n删除已保存的会话。使用 /resume 列出所有会话。",
|
|
324
324
|
"delete_confirmed": "✓ 会话 \"{name}\" 已删除。",
|
|
325
325
|
"delete_not_found": "✗ 未找到会话 \"{name}\"。使用 /resume 列出所有会话。",
|
|
326
326
|
"empty_footer": "会话在 Mipham Code 退出时自动保存到 ~/.mipham/sessions/。\n开始对话 — 它将自动保存。",
|
|
327
327
|
"total_footer": "总计: {count} 个会话 • 位置: ~/.mipham/sessions/",
|
|
328
|
-
"resume_hint": "
|
|
329
|
-
"resume_last_hint": "
|
|
328
|
+
"resume_hint": "查看会话详情: /resume <name>",
|
|
329
|
+
"resume_last_hint": "载入最近历史: /resume last",
|
|
330
330
|
"delete_hint": "删除会话: /resume delete <name>",
|
|
331
331
|
"cli_hint": "命令行恢复: mipham --resume \"<name>\"",
|
|
332
|
-
"auto_save_hint": "
|
|
332
|
+
"auto_save_hint": "会话在退出时自动保存。重启时仅注入最近会话的摘要作为上下文 —— 要重新打开某个会话: mipham --resume \"<name>\"",
|
|
333
333
|
"not_found_content": "未找到名为 \"{name}\" 的会话。\n\n不带参数运行 /resume 列出所有已保存的会话。",
|
|
334
|
-
"found_content": "名称: {name}\n消息数: {messages}\n提供商: {provider} / {model}\n更新时间: {updated}\n\n
|
|
334
|
+
"found_content": "名称: {name}\n消息数: {messages}\n提供商: {provider} / {model}\n更新时间: {updated}\n\n要接着此会话继续,请另起进程运行:\n mipham --resume \"{target}\""
|
|
335
335
|
},
|
|
336
336
|
"help": { "title": "── Mipham Code 帮助 ──" },
|
|
337
337
|
"lang": {
|
|
@@ -423,6 +423,7 @@
|
|
|
423
423
|
},
|
|
424
424
|
"upgrade": {
|
|
425
425
|
"uptodate": "── 升级 Mipham Code ──\n\n当前版本: v{current}\n最新版本: v{latest}\n\n✓ 已是最新版本。\n\n手动检查: https://www.npmjs.com/package/@miphamai/cli",
|
|
426
|
+
"check_failed": "── 升级 Mipham Code ──\n\n✗ 无法连接 npm registry —— 无法判断 v{current} 是否已是最新版本。\n 未做任何改动。请检查网络后重试: /upgrade",
|
|
426
427
|
"title": "── 升级 Mipham Code ──",
|
|
427
428
|
"available": "当前版本: v{current}\n最新版本: v{latest}\n\n→ 发现新版本!正在更新...",
|
|
428
429
|
"updated": "✓ 已更新至 @miphamai/cli v{version}",
|
|
@@ -558,7 +559,7 @@
|
|
|
558
559
|
"stats": {
|
|
559
560
|
"title": "── 会话统计 ──",
|
|
560
561
|
"messages": "消息: {total}({user} 用户, {assistant} AI, {system} 系统)",
|
|
561
|
-
"tokens": "Token: ~{tokens} /
|
|
562
|
+
"tokens": "Token: ~{tokens} / {max}",
|
|
562
563
|
"tools": "可用工具: {count}",
|
|
563
564
|
"provider": "提供商: {provider}",
|
|
564
565
|
"model": "模型: {model}",
|
|
@@ -585,6 +586,9 @@
|
|
|
585
586
|
"title": "── 钩子 ──",
|
|
586
587
|
"no_hooks": "── 钩子 ──\n\n未配置钩子。\n\n在 .mipham/settings.json 的 hooks 段配置钩子:\n PreToolUse — 工具执行前\n PostToolUse — 工具执行后\n Stop — 会话结束\n\n命令钩子从 stdin 读 JSON,exit 2 拦截(stderr 作理由)。\n使用 /loop init 初始化 settings.json。",
|
|
587
588
|
"location": "位置: {path}",
|
|
589
|
+
"source_user": "用户级",
|
|
590
|
+
"source_project": "项目级",
|
|
591
|
+
"gated": "⚠️ 工作区未受信任,不会运行",
|
|
588
592
|
"found": "找到 {count} 个钩子。",
|
|
589
593
|
"error": "── 钩子 ──\n\n无法读取钩子目录。"
|
|
590
594
|
},
|
package/src/index.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import { ExperienceRuleEngine } from './core/rule-engine.js'
|
|
|
32
32
|
import { SessionLog } from './core/session-log'
|
|
33
33
|
import { SessionStore } from './core/session-store'
|
|
34
34
|
import type { PermissionLevel, MiphamConfig, McpServerConfig } from './shared/types'
|
|
35
|
+
import { PermissionSystem } from './core/permission'
|
|
35
36
|
import { SkillsLoader } from './skills/loader'
|
|
36
37
|
import { PluginManager } from './plugin/plugin-manager'
|
|
37
38
|
import { loadPlugins } from './plugin/plugin-loader'
|
|
@@ -52,7 +53,7 @@ import { getMetrics } from './core/metrics'
|
|
|
52
53
|
import { initTelemetry, enableTelemetryNow } from './telemetry/index'
|
|
53
54
|
import { wasPrompted, markPrompted, isInteractive, setTelemetryEnabled } from './telemetry/consent'
|
|
54
55
|
import { officialEndpointHost } from './telemetry/endpoint'
|
|
55
|
-
import { getWorkspaceTrust } from './core/workspace-trust'
|
|
56
|
+
import { getWorkspaceTrust, warnProjectHooksSkipped } from './core/workspace-trust'
|
|
56
57
|
import { ARTIFACT_PORT } from './shared/constants'
|
|
57
58
|
import { artifactsRoot } from './artifacts/paths'
|
|
58
59
|
import { AgentViewManager } from './agent-view/agent-view-manager'
|
|
@@ -375,6 +376,22 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
375
376
|
// Load configuration
|
|
376
377
|
const config = loadConfig()
|
|
377
378
|
|
|
379
|
+
// Permission policy is built **here**, before the system prompt exists, so the prompt
|
|
380
|
+
// can describe the mode the engine will actually run in. Building the prompt from
|
|
381
|
+
// `config.permission` handed the model a value that org restrictions can silently
|
|
382
|
+
// rewrite (and that may not even be a mode name — `bypass`/`auto`/`ask` inject nothing
|
|
383
|
+
// at all). The engine is created much later, after tools/hooks/vajra are mounted, so
|
|
384
|
+
// this instance is what gets handed to it rather than a second one built inside.
|
|
385
|
+
const permission = new PermissionSystem('default')
|
|
386
|
+
// Sync with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
|
|
387
|
+
if (config.permission) {
|
|
388
|
+
permission.setDefaultLevel(config.permission as PermissionLevel)
|
|
389
|
+
}
|
|
390
|
+
// Apply org-level permission restrictions (P0: bypassPermissions policy gap)
|
|
391
|
+
if (config.permissionRestrictions) {
|
|
392
|
+
permission.setRestrictions(config.permissionRestrictions)
|
|
393
|
+
}
|
|
394
|
+
|
|
378
395
|
// Detect locale and create translation function
|
|
379
396
|
const locale = detectLocale({ lang: options.lang })
|
|
380
397
|
const t = createT(localeBundles[locale] || enUS, enUS)
|
|
@@ -479,12 +496,12 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
479
496
|
}
|
|
480
497
|
}
|
|
481
498
|
context.restoreLog(log)
|
|
482
|
-
context.setSystemPrompt(instructions.buildSystemPrompt(
|
|
499
|
+
context.setSystemPrompt(instructions.buildSystemPrompt(permission.getMode()))
|
|
483
500
|
}
|
|
484
501
|
}
|
|
485
502
|
|
|
486
503
|
if (context.getMessageCount() === 0) {
|
|
487
|
-
const basePrompt = instructions.buildSystemPrompt(
|
|
504
|
+
const basePrompt = instructions.buildSystemPrompt(permission.getMode())
|
|
488
505
|
const memoryReminder = loadSessionMemories(basePrompt)
|
|
489
506
|
const skillsReminder = skillsLoader.buildSystemReminder(5000, config.skills?.reminder ?? 'full')
|
|
490
507
|
|
|
@@ -582,8 +599,10 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
582
599
|
// A dead server (e.g. a 15s connect timeout) no longer blocks startup.
|
|
583
600
|
const mcpConnectPromise = connectMcpServers(config.skills?.mcpServers ?? [], tools)
|
|
584
601
|
|
|
585
|
-
// Initialize hook engine — register skill-defined hooks
|
|
586
|
-
|
|
602
|
+
// Initialize hook engine — register skill-defined hooks.
|
|
603
|
+
// CLI 是一次性进程,会话 cwd 就是 `process.cwd()`;写出来是为了与 daemon 那处
|
|
604
|
+
// (`daemon/engine-capabilities.ts` 传会话 cwd)成对照,别让哪边看起来像漏了。
|
|
605
|
+
const hookEngine = new HookEngine(process.cwd())
|
|
587
606
|
for (const skill of skillsLoader.list()) {
|
|
588
607
|
if (skill.hooks) {
|
|
589
608
|
for (const hook of skill.hooks) {
|
|
@@ -592,8 +611,17 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
592
611
|
}
|
|
593
612
|
}
|
|
594
613
|
|
|
595
|
-
// Register settings.json hooks (Claude Code convention — additive across levels)
|
|
596
|
-
|
|
614
|
+
// Register settings.json hooks (Claude Code convention — additive across levels).
|
|
615
|
+
// Project-level hooks are repository-controlled shell commands, so they are only
|
|
616
|
+
// read once this workspace is trusted. On a TTY `checkWorkspaceTrust()` above has
|
|
617
|
+
// already asked — and exited the process on "no". With no TTY it *cannot* ask, so
|
|
618
|
+
// the answer is "no" and the hooks stay out rather than running unasked; that
|
|
619
|
+
// skip is announced, since silence would look the same as having passed.
|
|
620
|
+
const projectHooksTrusted = getWorkspaceTrust().isTrusted(process.cwd())
|
|
621
|
+
const settingsJson = loadSettingsJson(process.cwd(), {
|
|
622
|
+
includeProjectHooks: projectHooksTrusted,
|
|
623
|
+
})
|
|
624
|
+
if (settingsJson.projectHooksSkipped) warnProjectHooksSkipped(process.cwd())
|
|
597
625
|
for (const def of loadHookConfigs(settingsJson.hooks)) {
|
|
598
626
|
hookEngine.register(def)
|
|
599
627
|
}
|
|
@@ -604,7 +632,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
604
632
|
|
|
605
633
|
// Create query engine
|
|
606
634
|
const ruleEngine = new ExperienceRuleEngine()
|
|
607
|
-
const engine = new QueryEngine(registry, context, tools,
|
|
635
|
+
const engine = new QueryEngine(registry, context, tools, permission, ruleEngine)
|
|
608
636
|
engine.setHookEngine(hookEngine)
|
|
609
637
|
engine.setArtifactServer(artifactServer)
|
|
610
638
|
engine.setAgentViewManager(agentViewManager)
|
|
@@ -619,15 +647,8 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
619
647
|
const inferenceHookConfig = loadInferenceHookConfig()
|
|
620
648
|
engine.setInferenceHookConfig(inferenceHookConfig)
|
|
621
649
|
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Apply org-level permission restrictions (P0: bypassPermissions policy gap)
|
|
628
|
-
if (config.permissionRestrictions) {
|
|
629
|
-
engine.getPermission().setRestrictions(config.permissionRestrictions)
|
|
630
|
-
}
|
|
650
|
+
// `config.permission` / `permissionRestrictions` are applied up top, on the instance
|
|
651
|
+
// handed to `new QueryEngine(...)` — the same one whose mode the system prompt reads.
|
|
631
652
|
|
|
632
653
|
// Apply user-defined permission rules (allow/deny) — wire the rule system into runtime
|
|
633
654
|
if (config.permissionRules) {
|
|
@@ -645,6 +666,12 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
645
666
|
process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
|
|
646
667
|
}
|
|
647
668
|
|
|
669
|
+
// Same warning channel for malformed restrictions (P1) — a typo'd
|
|
670
|
+
// `forbiddenModes`/`maxAllowedMode` used to leave the policy silently inert.
|
|
671
|
+
for (const msg of engine.getPermission().getInvalidRestrictions()) {
|
|
672
|
+
process.stderr.write(`⚠ Mipham Code: ${msg}\n`)
|
|
673
|
+
}
|
|
674
|
+
|
|
648
675
|
// Initialize agent registry and load plugin agents/skills/MCP/hooks
|
|
649
676
|
const agentRegistry = new AgentRegistry()
|
|
650
677
|
agentRegistry.loadUserAgents()
|
package/src/mcp/client.ts
CHANGED
|
@@ -187,6 +187,12 @@ export class McpClient {
|
|
|
187
187
|
connection.toolsRefreshInFlight = true
|
|
188
188
|
try {
|
|
189
189
|
await this.applyToolsChanged(name)
|
|
190
|
+
} catch {
|
|
191
|
+
// A refresh against a server that has gone away must not surface as an
|
|
192
|
+
// unhandled rejection out of a timer callback — record the lost
|
|
193
|
+
// connection instead.
|
|
194
|
+
const current = this.connections.get(name)
|
|
195
|
+
if (current) this.markIfTransportLost(name, current)
|
|
190
196
|
} finally {
|
|
191
197
|
// Re-read: the server may have been disconnected while we were awaiting.
|
|
192
198
|
const current = this.connections.get(name)
|
|
@@ -427,10 +433,28 @@ export class McpClient {
|
|
|
427
433
|
try {
|
|
428
434
|
return await conn.protocol.callTool(toolName, params)
|
|
429
435
|
} catch (err) {
|
|
436
|
+
this.markIfTransportLost(serverName, conn)
|
|
430
437
|
return {
|
|
431
438
|
content: [{ type: 'text', text: t('errors.mcp_tool_error', { error: String(err) }) }],
|
|
432
439
|
isError: true,
|
|
433
440
|
}
|
|
434
441
|
}
|
|
435
442
|
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Downgrade a connection whose transport has gone away.
|
|
446
|
+
*
|
|
447
|
+
* A tool call can fail because the *tool* failed or because the server did, and
|
|
448
|
+
* only the transport can tell those apart: a closed transport answers nothing
|
|
449
|
+
* from now on. Without this the connection stays 'connected' and `/mcp` keeps
|
|
450
|
+
* showing green for a server that is gone.
|
|
451
|
+
*/
|
|
452
|
+
private markIfTransportLost(name: string, connection: ActiveConnection): void {
|
|
453
|
+
if (connection.transport.isConnected()) return
|
|
454
|
+
if (connection.status === 'error') return
|
|
455
|
+
|
|
456
|
+
connection.status = 'error'
|
|
457
|
+
connection.error = 'Connection lost — the transport is no longer connected'
|
|
458
|
+
this.emit('disconnected', name, connection.error)
|
|
459
|
+
}
|
|
436
460
|
}
|
|
@@ -12,7 +12,10 @@ type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
|
|
|
12
12
|
* result text (this is how Forge's `/mcp` streams progressive chunks). We
|
|
13
13
|
* reassemble those slices so callers see one complete result.
|
|
14
14
|
*/
|
|
15
|
-
function parseSseResponse(
|
|
15
|
+
function parseSseResponse(
|
|
16
|
+
body: string,
|
|
17
|
+
onNotification?: (notification: JsonRpcNotification) => void,
|
|
18
|
+
): unknown {
|
|
16
19
|
const messages: Array<{ id?: number; result?: unknown; error?: JsonRpcError }> = []
|
|
17
20
|
|
|
18
21
|
for (const block of body.split(/\n\n/)) {
|
|
@@ -21,7 +24,16 @@ function parseSseResponse(body: string): unknown {
|
|
|
21
24
|
const payload = line.slice('data:'.length).trim()
|
|
22
25
|
if (!payload) continue
|
|
23
26
|
try {
|
|
24
|
-
|
|
27
|
+
const message = JSON.parse(payload) as JsonRpcResponse & JsonRpcNotification
|
|
28
|
+
// Server-initiated notifications (e.g. tools/list_changed) arrive on the
|
|
29
|
+
// same stream as the response to the request in flight. They carry no id
|
|
30
|
+
// and are not part of that response, so hand them off rather than letting
|
|
31
|
+
// them count as a result — or as a chunk of one.
|
|
32
|
+
if (message.method !== undefined && message.id === undefined) {
|
|
33
|
+
onNotification?.(message as JsonRpcNotification)
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
messages.push(message)
|
|
25
37
|
} catch {
|
|
26
38
|
// Skip unparseable event lines
|
|
27
39
|
}
|
|
@@ -111,6 +123,10 @@ export class HttpTransport implements Transport {
|
|
|
111
123
|
const controller = new AbortController()
|
|
112
124
|
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs)
|
|
113
125
|
|
|
126
|
+
// Did an HTTP response come back at all? A rejected fetch or a timeout means
|
|
127
|
+
// the endpoint is unreachable; an error *status* only means it said no.
|
|
128
|
+
let reachable = false
|
|
129
|
+
|
|
114
130
|
try {
|
|
115
131
|
const response = await this.fetchImpl(this.url, {
|
|
116
132
|
method: 'POST',
|
|
@@ -123,6 +139,7 @@ export class HttpTransport implements Transport {
|
|
|
123
139
|
signal: controller.signal,
|
|
124
140
|
redirect: 'manual',
|
|
125
141
|
})
|
|
142
|
+
reachable = true
|
|
126
143
|
|
|
127
144
|
if (!response.ok) {
|
|
128
145
|
let detail = `HTTP ${response.status}`
|
|
@@ -136,7 +153,7 @@ export class HttpTransport implements Transport {
|
|
|
136
153
|
|
|
137
154
|
const contentType = response.headers.get('content-type') || ''
|
|
138
155
|
if (contentType.includes('text/event-stream')) {
|
|
139
|
-
return parseSseResponse(await response.text())
|
|
156
|
+
return parseSseResponse(await response.text(), (n) => this.dispatchNotification(n))
|
|
140
157
|
}
|
|
141
158
|
|
|
142
159
|
const json = (await response.json()) as JsonRpcResponse
|
|
@@ -146,8 +163,16 @@ export class HttpTransport implements Transport {
|
|
|
146
163
|
return json.result
|
|
147
164
|
} catch (err) {
|
|
148
165
|
if (controller.signal.aborted) {
|
|
166
|
+
// The endpoint never answered. Every later request would hang the same
|
|
167
|
+
// way, so the transport reports itself disconnected — the same state a
|
|
168
|
+
// stdio transport reaches when its process exits — until reconnect()
|
|
169
|
+
// replaces it.
|
|
170
|
+
this.closed = true
|
|
149
171
|
throw requestTimeoutError(method, this.requestTimeoutMs)
|
|
150
172
|
}
|
|
173
|
+
if (!reachable) {
|
|
174
|
+
this.closed = true
|
|
175
|
+
}
|
|
151
176
|
throw err
|
|
152
177
|
} finally {
|
|
153
178
|
clearTimeout(timer)
|
|
@@ -182,6 +207,13 @@ export class HttpTransport implements Transport {
|
|
|
182
207
|
this.notificationHandlers.push(handler)
|
|
183
208
|
}
|
|
184
209
|
|
|
210
|
+
/** Hand a server-initiated notification to every registered handler. */
|
|
211
|
+
private dispatchNotification(notification: JsonRpcNotification): void {
|
|
212
|
+
for (const handler of this.notificationHandlers) {
|
|
213
|
+
handler(notification)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
185
217
|
async close(): Promise<void> {
|
|
186
218
|
this.closed = true
|
|
187
219
|
this.url = null
|
|
@@ -11,6 +11,7 @@ import { join } from 'node:path'
|
|
|
11
11
|
import { homedir } from 'node:os'
|
|
12
12
|
import { execFileSync } from 'node:child_process'
|
|
13
13
|
import { validatePlugin } from './plugin-validator'
|
|
14
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
14
15
|
|
|
15
16
|
const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
|
|
16
17
|
|
|
@@ -287,7 +288,12 @@ export class PluginManager {
|
|
|
287
288
|
private loadState(): void {
|
|
288
289
|
try {
|
|
289
290
|
if (existsSync(this.statePath)) {
|
|
290
|
-
|
|
291
|
+
const parsed: unknown = JSON.parse(readFileSync(this.statePath, 'utf-8'))
|
|
292
|
+
// Parsing is not validating: `{}` / `null` / `"x"` are all valid JSON and
|
|
293
|
+
// all make the readers below throw (`.find`/`.filter`/`.map` on a
|
|
294
|
+
// non-array), so a file that is merely *shaped* wrong took down every
|
|
295
|
+
// plugin command instead of starting empty.
|
|
296
|
+
this.plugins = Array.isArray(parsed) ? (parsed as InstalledPlugin[]) : []
|
|
291
297
|
}
|
|
292
298
|
} catch {
|
|
293
299
|
this.plugins = []
|
|
@@ -295,6 +301,11 @@ export class PluginManager {
|
|
|
295
301
|
}
|
|
296
302
|
|
|
297
303
|
private saveState(): void {
|
|
298
|
-
writeFileSync
|
|
304
|
+
// Was a bare writeFileSync: an interrupted write left unparseable JSON, and
|
|
305
|
+
// `loadState` swallows that into `[]` ⇒ the installed-plugin list silently
|
|
306
|
+
// vanished. Atomic write means a reader sees the old list or the new one.
|
|
307
|
+
atomicWriteFileSync(this.statePath, JSON.stringify(this.plugins, null, 2) + '\n', {
|
|
308
|
+
mode: 0o600,
|
|
309
|
+
})
|
|
299
310
|
}
|
|
300
311
|
}
|
|
@@ -39,6 +39,27 @@ interface AnthropicSSEEvent {
|
|
|
39
39
|
usage?: { input_tokens: number; output_tokens: number }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Stand-in for a tool result that has no content of its own.
|
|
44
|
+
*
|
|
45
|
+
* `tool_result.content` is normalized to a text block server-side, and an empty
|
|
46
|
+
* one is rejected with a 400 — which fails the *whole* request, history
|
|
47
|
+
* included. Multiple paths produce one: a tool that reports success with no
|
|
48
|
+
* output (`content: ''`), a failed tool with no message, and a log projection
|
|
49
|
+
* whose `content` never got written (`undefined`). All of them mean the same
|
|
50
|
+
* thing to the model, so they get the same words.
|
|
51
|
+
*/
|
|
52
|
+
const NO_TOOL_OUTPUT = '(no output)'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An empty text block is not a stylistic wart — Anthropic rejects it with a 400,
|
|
56
|
+
* and since every message in the request is history, one such block makes the
|
|
57
|
+
* conversation permanently unsendable (every later turn re-sends it).
|
|
58
|
+
*/
|
|
59
|
+
function isEmptyTextBlock(block: Record<string, unknown>): boolean {
|
|
60
|
+
return block.type === 'text' && block.text === ''
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
export class AnthropicProvider implements ProviderInstance {
|
|
43
64
|
private baseUrl = 'https://api.anthropic.com/v1'
|
|
44
65
|
private anthropicVersion = '2023-06-01'
|
|
@@ -302,17 +323,23 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
302
323
|
if (msg.role === 'system') continue
|
|
303
324
|
|
|
304
325
|
if (typeof msg.content === 'string') {
|
|
305
|
-
const content: unknown[] = [
|
|
326
|
+
const content: unknown[] = []
|
|
327
|
+
// 空串不产出 text 块(见 isEmptyTextBlock):用户发了个空消息、或工具返回
|
|
328
|
+
// 空内容时会走到这里,而它会让之后每一轮都 400。
|
|
329
|
+
if (msg.content !== '') content.push({ type: 'text', text: msg.content })
|
|
306
330
|
// DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
|
|
307
331
|
// message must contain a thinking block if any message in history does.
|
|
308
332
|
if (msg.role === 'assistant') {
|
|
309
333
|
const thinkingText = (msg as any).reasoning_content || ''
|
|
310
334
|
content.unshift({ type: 'thinking', thinking: thinkingText })
|
|
311
335
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
336
|
+
// 全部内容都被滤掉的消息**整条**不下发 —— 空 content 数组同样被 API 拒。
|
|
337
|
+
if (content.length > 0) {
|
|
338
|
+
result.push({
|
|
339
|
+
role: msg.role,
|
|
340
|
+
content,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
316
343
|
} else {
|
|
317
344
|
const blocks = (msg.content as ContentBlock[]).map((block) => {
|
|
318
345
|
switch (block.type) {
|
|
@@ -362,22 +389,32 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
362
389
|
return {
|
|
363
390
|
type: 'tool_result',
|
|
364
391
|
tool_use_id: block.tool_use_id,
|
|
365
|
-
content
|
|
392
|
+
// 空 content 同样整条请求被拒(见 NO_TOOL_OUTPUT)。`||` 而非 `=== ''`:
|
|
393
|
+
// 投影层缺失该字段时这里是 `undefined`,空数组同理。
|
|
394
|
+
content: block.content || NO_TOOL_OUTPUT,
|
|
366
395
|
// 只在失败时下发 —— 成功请求体与改动前逐字节相同,不引入 prompt-cache 前缀抖动
|
|
367
396
|
...(block.is_error === true ? { is_error: true } : {}),
|
|
368
397
|
}
|
|
369
398
|
|
|
370
399
|
default:
|
|
371
|
-
|
|
400
|
+
// 未知块类型**无法**原样表达,而 `{type:'text',text:''}` 是一个必然被拒
|
|
401
|
+
// 的载荷 —— 改成丢这一个块(下面的 filter),而不是拿它毒掉整条请求。
|
|
402
|
+
return null
|
|
372
403
|
}
|
|
373
|
-
})
|
|
404
|
+
}) as (Record<string, unknown> | null)[]
|
|
405
|
+
|
|
406
|
+
// 丢掉表达不出来的块(未知类型)与空的 text 块。
|
|
407
|
+
const kept = blocks.filter(
|
|
408
|
+
(b): b is Record<string, unknown> => b !== null && !isEmptyTextBlock(b),
|
|
409
|
+
)
|
|
374
410
|
|
|
375
411
|
// DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
|
|
376
412
|
// message must contain a thinking block if any message in history does.
|
|
377
|
-
if (msg.role === 'assistant' && !
|
|
378
|
-
|
|
413
|
+
if (msg.role === 'assistant' && !kept.some((b) => b.type === 'thinking')) {
|
|
414
|
+
kept.unshift({ type: 'thinking', thinking: '' })
|
|
379
415
|
}
|
|
380
|
-
|
|
416
|
+
if (kept.length === 0) continue
|
|
417
|
+
result.push({ role: msg.role, content: kept })
|
|
381
418
|
}
|
|
382
419
|
}
|
|
383
420
|
|
package/src/security/gate.ts
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecurityGate — 判定函数集合。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **本文件不是一个已生效的安全门。** 它是一个**判定函数的库**,其中只有一部分
|
|
5
|
+
* 被接进生产路径。别把「文件被 import」读成「这些检查在生产里跑」:
|
|
6
|
+
*
|
|
7
|
+
* - `redactCredentialLeak` — **已接线**(`core/behavior-tasks.ts`、
|
|
8
|
+
* `core/credential-masker/output-scrub.ts`)。
|
|
9
|
+
* - 其余三个 `check*` 方法 — **生产零调用点**,唯一消费者是
|
|
10
|
+
* `test/security/penetration/`。因此 CI 的 `penetration-test` job 全绿只说明
|
|
11
|
+
* **这些判定函数被自己测过**,不说明生产有这三道防线。三条各自的去路(为什么不接、
|
|
12
|
+
* 生产另有哪条防线)登记在
|
|
13
|
+
* `test/integrity/unwired-disposition.test.ts` 的 `KEPT_UNWIRED_METHODS` 里,
|
|
14
|
+
* 由机器两向强制(存在 + 生产零引用)。
|
|
15
|
+
*
|
|
16
|
+
* 想让某条真正生效时:先读那条登记的理由,再决定是接线还是改判据 —— 直接 `import`
|
|
17
|
+
* 进来调是最容易的一步,也是最容易多出一道更弱、更难维护、判据还与被取代者不一致的门。
|
|
18
|
+
*/
|
|
1
19
|
export interface GateResult {
|
|
2
20
|
blocked: boolean
|
|
3
21
|
reason?: string
|
package/src/security/path.ts
CHANGED
|
@@ -7,6 +7,24 @@ import { realpathSync, existsSync } from 'node:fs'
|
|
|
7
7
|
*/
|
|
8
8
|
const BLOCKED_PATHS = ['/etc', '/proc', '/sys', '/dev', '/boot', '/root']
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The same list in canonical form.
|
|
12
|
+
*
|
|
13
|
+
* The check below compares against an already-resolved path, so the entries
|
|
14
|
+
* must be resolved too: on macOS `/etc` is a symlink to `/private/etc`, and
|
|
15
|
+
* comparing `/private/etc/x` against the literal `/etc` never matched — the
|
|
16
|
+
* check was dead there. Entries that don't exist yet keep their literal form
|
|
17
|
+
* (there is nothing to resolve, and a later `realpathSync` of the same path
|
|
18
|
+
* cannot produce a different spelling).
|
|
19
|
+
*/
|
|
20
|
+
const BLOCKED_CANONICAL: string[] = BLOCKED_PATHS.map((p) => {
|
|
21
|
+
try {
|
|
22
|
+
return existsSync(p) ? realpathSync(p) : p
|
|
23
|
+
} catch {
|
|
24
|
+
return p
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
10
28
|
/**
|
|
11
29
|
* Windows UNC paths and NT/Win32/DOS device-namespace prefixes.
|
|
12
30
|
*
|
|
@@ -86,7 +104,7 @@ export function resolveSafe(cwd: string, inputPath: string): string {
|
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
// Check 2: must not target sensitive system directories
|
|
89
|
-
for (const blocked of
|
|
107
|
+
for (const blocked of BLOCKED_CANONICAL) {
|
|
90
108
|
if (canonical === blocked || canonical.startsWith(blocked + '/')) {
|
|
91
109
|
throw new Error(
|
|
92
110
|
`Path rejected: "${inputPath}" resolves to a protected system directory (${blocked}).`,
|