@miphamai/cli 0.50.0 → 0.52.0
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 +1 -9
- package/package.json +1 -2
- package/skills/standard/web-access/references/cdp-api.md +2 -1
- package/skills/standard/web-access/scripts/cdp-proxy.mjs +75 -1
- package/skills/standard/web-access/scripts/check-deps.mjs +17 -2
- package/skills/standard/web-access/scripts/find-url.mjs +0 -0
- package/skills/standard/web-access/scripts/match-site.mjs +0 -0
- package/skills/standard/web-access.SKILL.md +10 -3
- package/src/commands/keys.ts +26 -0
- package/src/commands/project.ts +1 -1
- package/src/config/credential-crypto.ts +114 -0
- package/src/config/defaults.ts +6 -1
- package/src/config/loader.ts +65 -14
- package/src/core/auto-memory.ts +3 -3
- package/src/core/constitution-loader.ts +0 -2
- package/src/core/context.ts +0 -1
- package/src/core/credential-masker/index.ts +1 -0
- package/src/core/credential-masker/matcher.ts +18 -7
- package/src/core/credential-masker/search.ts +73 -0
- package/src/core/credential-masker.ts +1 -0
- package/src/core/crsi-sandbox.ts +0 -1
- package/src/core/dream-engine.ts +0 -2
- package/src/core/instructions.ts +36 -12
- package/src/core/memory/memory-loader.ts +3 -1
- package/src/core/permission-rules.ts +23 -12
- package/src/core/rules-loader.ts +8 -10
- package/src/daemon/database.ts +0 -1
- package/src/daemon/feishu/adapter.ts +2 -1
- package/src/daemon/index.ts +18 -8
- package/src/daemon/server.ts +53 -10
- package/src/daemon/session-manager.ts +5 -4
- package/src/daemon/telegram/adapter.ts +70 -0
- package/src/daemon/telegram/api.ts +45 -0
- package/src/daemon/telegram/env.ts +14 -0
- package/src/daemon/telegram/poller.ts +64 -0
- package/src/daemon/telegram/types.ts +10 -0
- package/src/i18n-core/locales/en-US.json +123 -0
- package/src/i18n-core/locales/zh-CN.json +123 -0
- package/src/index.tsx +10 -8
- package/src/mcp/token-store.ts +2 -37
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +5 -3
- package/src/skills/bin-check.ts +40 -0
- package/src/skills/bundled-skill-assets.ts +5 -5
- package/src/skills/bundled-skills.ts +1 -1
- package/src/skills/loader.ts +35 -10
- package/src/skills/sanitizer.ts +1 -2
- package/src/skills/seam.ts +1 -1
- package/src/tools/agent/exit-plan.ts +1 -3
- package/src/tools/agent/memory.ts +2 -1
- package/src/tools/agent/skill.ts +14 -0
- package/src/tools/file/glob.ts +38 -23
- package/src/tools/file/grep.ts +86 -64
- package/src/tools/index.ts +5 -5
- package/src/ui/commands.ts +191 -119
- package/src/ui/config-wizard.tsx +5 -2
- package/src/core/tokenizer.ts +0 -80
package/bin/mipham.ts
CHANGED
|
@@ -530,9 +530,6 @@ async function runAgentCLI(): Promise<boolean> {
|
|
|
530
530
|
if (args[0] !== 'agents' && args[0] !== 'agent') return false
|
|
531
531
|
|
|
532
532
|
const { getPort, getDaemonStatus } = await import('../src/daemon/index')
|
|
533
|
-
const { join } = await import('node:path')
|
|
534
|
-
const { readFileSync, existsSync } = await import('node:fs')
|
|
535
|
-
const { homedir } = await import('node:os')
|
|
536
533
|
|
|
537
534
|
// Check if daemon is running
|
|
538
535
|
const status = getDaemonStatus()
|
|
@@ -542,11 +539,6 @@ async function runAgentCLI(): Promise<boolean> {
|
|
|
542
539
|
}
|
|
543
540
|
|
|
544
541
|
const port = getPort()
|
|
545
|
-
const tokenPath = join(homedir(), '.mipham', 'daemon.token')
|
|
546
|
-
let token = ''
|
|
547
|
-
if (existsSync(tokenPath)) {
|
|
548
|
-
token = readFileSync(tokenPath, 'utf-8').trim()
|
|
549
|
-
}
|
|
550
542
|
|
|
551
543
|
const baseUrl = `http://127.0.0.1:${port}`
|
|
552
544
|
|
|
@@ -1020,7 +1012,7 @@ async function runTokenCLI(): Promise<boolean> {
|
|
|
1020
1012
|
if (args[0] !== 'token') return false
|
|
1021
1013
|
|
|
1022
1014
|
const { join } = await import('node:path')
|
|
1023
|
-
const {
|
|
1015
|
+
const { existsSync } = await import('node:fs')
|
|
1024
1016
|
const { homedir } = await import('node:os')
|
|
1025
1017
|
const { rotateToken, listTokens } = await import('../src/daemon/auth')
|
|
1026
1018
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miphamai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
"commander": "^13.1.0",
|
|
46
46
|
"ink": "^5.2.1",
|
|
47
47
|
"ink-text-input": "^6.0.0",
|
|
48
|
-
"js-tiktoken": "^1.0.21",
|
|
49
48
|
"react": "^18.3.1",
|
|
50
49
|
"react-devtools-core": "^4.28.5",
|
|
51
50
|
"yaml": "^2.9.0"
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
## 基础信息
|
|
4
4
|
|
|
5
5
|
- 地址:`http://localhost:3456`
|
|
6
|
-
- 启动:`node ~/.
|
|
6
|
+
- 启动:`node ~/.mipham/skills/web-access/scripts/cdp-proxy.mjs &`
|
|
7
7
|
- 启动后持续运行,不建议主动停止(重启需 Chrome 重新授权)
|
|
8
8
|
- 强制停止:`pkill -f cdp-proxy.mjs`
|
|
9
|
+
- 鉴权:除 `/health` 外,所有端点要求请求头 `X-CDP-Token`,值 = `~/.mipham/skills/web-access/.cdp-token` 的内容(下方示例省略此头)
|
|
9
10
|
|
|
10
11
|
## API 端点
|
|
11
12
|
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// Node.js 22+(使用原生 WebSocket)
|
|
5
5
|
|
|
6
6
|
import http from 'node:http'
|
|
7
|
-
import { URL } from 'node:url'
|
|
7
|
+
import { URL, fileURLToPath } from 'node:url'
|
|
8
8
|
import fs from 'node:fs'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import os from 'node:os'
|
|
11
11
|
import net from 'node:net'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
12
13
|
|
|
13
14
|
const PORT = parseInt(process.env.CDP_PROXY_PORT || '3456')
|
|
14
15
|
let ws = null
|
|
@@ -19,6 +20,56 @@ const managedTabs = new Map() // targetId -> { lastAccessed: number }
|
|
|
19
20
|
const TAB_IDLE_TIMEOUT = parseInt(process.env.CDP_TAB_IDLE_TIMEOUT || '900000') // 15 min default
|
|
20
21
|
const CLEANUP_INTERVAL = 60000 // sweep every 60s
|
|
21
22
|
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
|
+
const TOKEN_FILE = path.join(ROOT, '.cdp-token')
|
|
25
|
+
|
|
26
|
+
// --- 共享密钥鉴权 ---
|
|
27
|
+
// 防御浏览器 CSRF 到 localhost:恶意网页能 fetch 127.0.0.1:3456,但读不到本文件。
|
|
28
|
+
let authToken = null
|
|
29
|
+
function readToken() {
|
|
30
|
+
try {
|
|
31
|
+
const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim()
|
|
32
|
+
if (t) return t
|
|
33
|
+
} catch {
|
|
34
|
+
/* 尚未生成 */
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
function ensureToken() {
|
|
39
|
+
const existing = readToken()
|
|
40
|
+
if (existing) {
|
|
41
|
+
authToken = existing
|
|
42
|
+
return existing
|
|
43
|
+
}
|
|
44
|
+
authToken = crypto.randomBytes(32).toString('hex')
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(TOKEN_FILE, authToken, { mode: 0o600 })
|
|
47
|
+
} catch {
|
|
48
|
+
/* 无写权限时仅内存持有,进程存活期内仍可鉴权 */
|
|
49
|
+
}
|
|
50
|
+
return authToken
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- 窄 SSRF 防护 ---
|
|
54
|
+
// 本工具用途即访问组织内网(SSO 后台/内部系统),故不做私有网段全量屏蔽;
|
|
55
|
+
// 仅挡云元数据端点与非 http(s)/about 协议。完整 rebinding 防护见 src/security/url.ts。
|
|
56
|
+
function validateNavUrl(rawUrl) {
|
|
57
|
+
let u
|
|
58
|
+
try {
|
|
59
|
+
u = new URL(rawUrl)
|
|
60
|
+
} catch {
|
|
61
|
+
return { ok: false, reason: 'invalid URL' }
|
|
62
|
+
}
|
|
63
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:' && u.protocol !== 'about:') {
|
|
64
|
+
return { ok: false, reason: `unsupported protocol: ${u.protocol}` }
|
|
65
|
+
}
|
|
66
|
+
const host = u.hostname.toLowerCase()
|
|
67
|
+
if (host === '169.254.169.254' || host === 'metadata.google.internal') {
|
|
68
|
+
return { ok: false, reason: 'blocked: cloud metadata endpoint' }
|
|
69
|
+
}
|
|
70
|
+
return { ok: true }
|
|
71
|
+
}
|
|
72
|
+
|
|
22
73
|
// --- WebSocket 兼容层 ---
|
|
23
74
|
let WS
|
|
24
75
|
if (typeof globalThis.WebSocket !== 'undefined') {
|
|
@@ -378,6 +429,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
378
429
|
return
|
|
379
430
|
}
|
|
380
431
|
|
|
432
|
+
// 鉴权:非 /health 端点要求 X-CDP-Token(防浏览器 CSRF 到 localhost)
|
|
433
|
+
const provided = req.headers['x-cdp-token'] || ''
|
|
434
|
+
if (!authToken || provided !== authToken) {
|
|
435
|
+
res.statusCode = 401
|
|
436
|
+
res.end(JSON.stringify({ error: 'unauthorized: missing or invalid X-CDP-Token' }))
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
|
|
381
440
|
await connect()
|
|
382
441
|
|
|
383
442
|
// GET /targets - 列出所有页面
|
|
@@ -390,6 +449,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
390
449
|
// GET /new?url=xxx - 创建新后台 tab
|
|
391
450
|
else if (pathname === '/new') {
|
|
392
451
|
const targetUrl = q.url || 'about:blank'
|
|
452
|
+
const check = validateNavUrl(targetUrl)
|
|
453
|
+
if (!check.ok) {
|
|
454
|
+
res.statusCode = 400
|
|
455
|
+
res.end(JSON.stringify({ error: check.reason }))
|
|
456
|
+
return
|
|
457
|
+
}
|
|
393
458
|
const resp = await sendCDP('Target.createTarget', { url: targetUrl, background: true })
|
|
394
459
|
const targetId = resp.result.targetId
|
|
395
460
|
managedTabs.set(targetId, { lastAccessed: Date.now() })
|
|
@@ -417,6 +482,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
417
482
|
|
|
418
483
|
// GET /navigate?target=xxx&url=yyy - 导航(自动等待加载)
|
|
419
484
|
else if (pathname === '/navigate') {
|
|
485
|
+
const check = validateNavUrl(q.url || '')
|
|
486
|
+
if (!check.ok) {
|
|
487
|
+
res.statusCode = 400
|
|
488
|
+
res.end(JSON.stringify({ error: check.reason }))
|
|
489
|
+
return
|
|
490
|
+
}
|
|
420
491
|
const sid = await ensureSession(q.target)
|
|
421
492
|
const resp = await sendCDP('Page.navigate', { url: q.url }, sid)
|
|
422
493
|
|
|
@@ -723,6 +794,9 @@ async function main() {
|
|
|
723
794
|
process.exit(1)
|
|
724
795
|
}
|
|
725
796
|
|
|
797
|
+
// 只有成功绑定端口的实例才生成/复用 token(避免多实例竞态)
|
|
798
|
+
ensureToken()
|
|
799
|
+
|
|
726
800
|
server.listen(PORT, '127.0.0.1', () => {
|
|
727
801
|
console.log(`[CDP Proxy] 运行在 http://localhost:${PORT}`)
|
|
728
802
|
// 启动时尝试连接 Chrome(非阻塞)
|
|
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
11
11
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
12
|
const PROXY_SCRIPT = path.join(ROOT, 'scripts', 'cdp-proxy.mjs')
|
|
13
13
|
const PROXY_PORT = Number(process.env.CDP_PROXY_PORT || 3456)
|
|
14
|
+
const TOKEN_FILE = path.join(ROOT, '.cdp-token')
|
|
14
15
|
|
|
15
16
|
// --- Node.js 版本检查 ---
|
|
16
17
|
|
|
@@ -81,7 +82,7 @@ async function detectChromePort() {
|
|
|
81
82
|
if (port > 0 && port < 65536 && (await checkPort(port))) {
|
|
82
83
|
return port
|
|
83
84
|
}
|
|
84
|
-
} catch
|
|
85
|
+
} catch {}
|
|
85
86
|
}
|
|
86
87
|
// 回退:探测常见端口
|
|
87
88
|
for (const port of [9222, 9229, 9333]) {
|
|
@@ -94,8 +95,22 @@ async function detectChromePort() {
|
|
|
94
95
|
|
|
95
96
|
// --- CDP Proxy 启动与等待 ---
|
|
96
97
|
|
|
98
|
+
function readToken() {
|
|
99
|
+
try {
|
|
100
|
+
const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim()
|
|
101
|
+
if (t) return t
|
|
102
|
+
} catch {
|
|
103
|
+
/* proxy 尚未生成 token */
|
|
104
|
+
}
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
function httpGetJson(url, timeoutMs = 3000) {
|
|
98
|
-
|
|
109
|
+
const token = readToken()
|
|
110
|
+
return fetch(url, {
|
|
111
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
112
|
+
headers: token ? { 'X-CDP-Token': token } : {},
|
|
113
|
+
})
|
|
99
114
|
.then(async (res) => {
|
|
100
115
|
try {
|
|
101
116
|
return JSON.parse(await res.text())
|
|
File without changes
|
|
File without changes
|
|
@@ -44,7 +44,14 @@ node ~/.mipham/skills/web-access/scripts/check-deps.mjs
|
|
|
44
44
|
|
|
45
45
|
通过 CDP Proxy 直连用户日常 Chrome,天然携带登录态。**不主动操作用户已有 tab**,所有操作在自己创建的后台 tab 中进行,任务结束关闭自建 tab(保留用户原 tab)。
|
|
46
46
|
|
|
47
|
-
Proxy(`scripts/cdp-proxy.mjs`)由 `check-deps.mjs` 自动拉起并常驻。Proxy
|
|
47
|
+
Proxy(`scripts/cdp-proxy.mjs`)由 `check-deps.mjs` 自动拉起并常驻。Proxy 首次启动生成共享密钥 `~/.mipham/skills/web-access/.cdp-token`(0600),除 `/health` 外所有端点都要求请求头 `X-CDP-Token`。先取 token 再调 API:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
TOKEN=$(cat ~/.mipham/skills/web-access/.cdp-token)
|
|
51
|
+
curl -H "X-CDP-Token: $TOKEN" http://localhost:3456/targets
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
端点列表:
|
|
48
55
|
|
|
49
56
|
| 端点 | 用途 |
|
|
50
57
|
| ------------------------------------------ | ------------------------------------------------------------------------ |
|
|
@@ -122,8 +129,8 @@ Sources:
|
|
|
122
129
|
- 不主动操作用户已有 tab;任务结束关闭自建 tab。
|
|
123
130
|
- 不提交凭据(除非用户显式批准)。
|
|
124
131
|
- 尊重 robots.txt 与速率限制;不抓 PII。
|
|
125
|
-
- proxy 仅绑 127.0.0.1
|
|
126
|
-
-
|
|
132
|
+
- proxy 仅绑 127.0.0.1,不暴露外网;除 `/health` 外所有端点要求 `X-CDP-Token` 共享密钥(`~/.mipham/skills/web-access/.cdp-token`,0600),防浏览器 CSRF 到 localhost。勿在共享/多用户主机运行。
|
|
133
|
+
- URL 过窄 SSRF 校验:拒绝非 http(s)/about 协议与云元数据端点(`169.254.169.254` / `metadata.google.internal`)。⚠️ 私有网段(10.x / 172.16 / 192.168 / localhost)**有意放行**——本工具用途即访问组织内网(SSO 后台/内部系统),与 web-fetch 的完整 SSRF 屏蔽不同。
|
|
127
134
|
|
|
128
135
|
## 何时不用本 skill
|
|
129
136
|
|
package/src/commands/keys.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CommandHandler } from '../ui/commands'
|
|
2
2
|
import { KeyManager } from '../config/keys-manager'
|
|
3
|
+
import { getProviderApiKey } from '../config/loader'
|
|
3
4
|
|
|
4
5
|
export const keysCmd: CommandHandler = async (_ctx, args) => {
|
|
5
6
|
const manager = new KeyManager()
|
|
@@ -58,6 +59,31 @@ export const keysCmd: CommandHandler = async (_ctx, args) => {
|
|
|
58
59
|
return { content: lines.join('\n') }
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
// /keys view <provider> — show the plaintext API key (decrypting if stored encrypted)
|
|
63
|
+
if (sub === 'view' || sub === 'show') {
|
|
64
|
+
const provider = args[1]
|
|
65
|
+
if (!provider) {
|
|
66
|
+
return { content: 'Usage: /keys view <provider>\n\nExample: /keys view deepseek' }
|
|
67
|
+
}
|
|
68
|
+
const key = getProviderApiKey(provider)
|
|
69
|
+
if (key === null) {
|
|
70
|
+
return {
|
|
71
|
+
content: `No API key found for provider "${provider}".\n\nIf it was stored encrypted, it may be unreadable (missing/corrupt credential key).`,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
'── Key View ──',
|
|
77
|
+
'',
|
|
78
|
+
`Provider: ${provider}`,
|
|
79
|
+
`API Key: ${key}`,
|
|
80
|
+
'',
|
|
81
|
+
'⚠️ This is your plaintext API key. Do not share it or paste it anywhere.',
|
|
82
|
+
' Rotate it at any time with /keys rotate <provider>.',
|
|
83
|
+
].join('\n'),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
// /keys (list)
|
|
62
88
|
const keys = manager.list()
|
|
63
89
|
|
package/src/commands/project.ts
CHANGED
|
@@ -1015,7 +1015,7 @@ const securityCmd: CommandHandler = async () => {
|
|
|
1015
1015
|
return { content: lines.join('\n') }
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
1018
|
-
const trustCmd: CommandHandler = (
|
|
1018
|
+
const trustCmd: CommandHandler = (_ctx) => {
|
|
1019
1019
|
const trust = getWorkspaceTrust()
|
|
1020
1020
|
const cwd = process.cwd()
|
|
1021
1021
|
const trusted = trust.listTrusted()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
chmodSync,
|
|
7
|
+
copyFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shared AES-256-GCM primitives for encrypting credentials at rest.
|
|
14
|
+
*
|
|
15
|
+
* Extracted from the MCP token store so config.yml API keys get the exact
|
|
16
|
+
* same at-rest protection (parent/subsidiary CLAUDE.md mandate "存储层
|
|
17
|
+
* AES-256-GCM"). The wire format `iv || authTag || ciphertext` (base64) is
|
|
18
|
+
* preserved verbatim from the original token-store implementation so existing
|
|
19
|
+
* `~/.mipham/mcp-tokens/*.enc` files remain decryptable.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const ALGORITHM = 'aes-256-gcm'
|
|
23
|
+
const IV_LENGTH = 16
|
|
24
|
+
const AUTH_TAG_LENGTH = 16
|
|
25
|
+
const KEY_LENGTH = 32
|
|
26
|
+
|
|
27
|
+
/** Prefix marking an encrypted API key value in config.yml. */
|
|
28
|
+
export const ENC_PREFIX = 'enc:v1:'
|
|
29
|
+
|
|
30
|
+
/** Shared master-credential key file (used by both MCP tokens and config.yml). */
|
|
31
|
+
const CREDENTIAL_KEY_FILENAME = '.cred-key'
|
|
32
|
+
/** Pre-shared-key filename; migrated to {@link CREDENTIAL_KEY_FILENAME} on first use. */
|
|
33
|
+
const LEGACY_KEY_FILENAME = '.mcp-key'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load the shared credential key from `keyDir`, migrating the legacy
|
|
37
|
+
* MCP-only `.mcp-key` to the shared `.cred-key` so existing encrypted MCP
|
|
38
|
+
* tokens stay decryptable. Falls back to creating a fresh key when neither
|
|
39
|
+
* exists.
|
|
40
|
+
*/
|
|
41
|
+
export function getCredentialKey(keyDir: string): Buffer {
|
|
42
|
+
const keyPath = join(keyDir, CREDENTIAL_KEY_FILENAME)
|
|
43
|
+
if (!existsSync(keyPath)) {
|
|
44
|
+
const legacyPath = join(keyDir, LEGACY_KEY_FILENAME)
|
|
45
|
+
if (existsSync(legacyPath)) {
|
|
46
|
+
copyFileSync(legacyPath, keyPath)
|
|
47
|
+
chmodSync(keyPath, 0o400)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return getOrCreateKey(keyPath)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load the 32-byte encryption key from `keyPath`, creating a fresh random key
|
|
55
|
+
* (with owner-only read permissions) on first use.
|
|
56
|
+
*/
|
|
57
|
+
export function getOrCreateKey(keyPath: string): Buffer {
|
|
58
|
+
if (existsSync(keyPath)) {
|
|
59
|
+
return readFileSync(keyPath)
|
|
60
|
+
}
|
|
61
|
+
const key = randomBytes(KEY_LENGTH)
|
|
62
|
+
mkdirSync(dirname(keyPath), { recursive: true })
|
|
63
|
+
writeFileSync(keyPath, key)
|
|
64
|
+
chmodSync(keyPath, 0o400)
|
|
65
|
+
return key
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Encrypt plaintext with AES-256-GCM. Returns `base64(iv || authTag || ciphertext)`. */
|
|
69
|
+
export function encrypt(plaintext: string, key: Buffer): string {
|
|
70
|
+
const iv = randomBytes(IV_LENGTH)
|
|
71
|
+
const cipher = createCipheriv(ALGORITHM, key, iv)
|
|
72
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()])
|
|
73
|
+
const authTag = cipher.getAuthTag()
|
|
74
|
+
return Buffer.concat([iv, authTag, encrypted]).toString('base64')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Decrypt a value produced by {@link encrypt}. Throws on wrong key / corrupt data. */
|
|
78
|
+
export function decrypt(ciphertext: string, key: Buffer): string {
|
|
79
|
+
const buf = Buffer.from(ciphertext, 'base64')
|
|
80
|
+
const iv = buf.subarray(0, IV_LENGTH)
|
|
81
|
+
const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
|
|
82
|
+
const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
|
|
83
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv)
|
|
84
|
+
decipher.setAuthTag(authTag)
|
|
85
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True when `value` is an environment-variable template rather than a literal
|
|
90
|
+
* secret — both `${VAR}` and `$VAR` forms (the provider resolvers accept both).
|
|
91
|
+
*/
|
|
92
|
+
export function isEnvTemplate(value: string): boolean {
|
|
93
|
+
return /^\$\{.*\}$/.test(value) || /^\$[A-Z_][A-Z0-9_]*$/.test(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Encrypt an API key for storage. Environment-variable templates and empty
|
|
98
|
+
* values are not secrets, so they pass through unchanged (keeping config.yml
|
|
99
|
+
* readable for env-based setups). Literal secrets get the `enc:v1:` prefix.
|
|
100
|
+
*/
|
|
101
|
+
export function encryptApiKey(apiKey: string, key: Buffer): string {
|
|
102
|
+
if (!apiKey || isEnvTemplate(apiKey)) return apiKey
|
|
103
|
+
return ENC_PREFIX + encrypt(apiKey, key)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Decrypt a stored API key. Plaintext (legacy or template) values pass through
|
|
108
|
+
* unchanged; `enc:v1:` values are decrypted. Throws on a missing/corrupt key so
|
|
109
|
+
* the caller can surface a clear error instead of sending a garbage key.
|
|
110
|
+
*/
|
|
111
|
+
export function decryptApiKey(stored: string, key: Buffer): string {
|
|
112
|
+
if (!stored.startsWith(ENC_PREFIX)) return stored
|
|
113
|
+
return decrypt(stored.slice(ENC_PREFIX.length), key)
|
|
114
|
+
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -16,6 +16,11 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
16
16
|
// Org 级权限限制(可选):forbiddenModes 禁指定模式 / maxAllowedMode 封顶层级;
|
|
17
17
|
// 请求被禁模式时 fail-closed 降级(如 forbiddenModes:['bypassPermissions'])。
|
|
18
18
|
// permissionRestrictions: { forbiddenModes: ['bypassPermissions'] },
|
|
19
|
+
// 用户自定义权限规则(可选):allow/deny 接入运行时 PermissionSystem。
|
|
20
|
+
// mask 与 deny 互补、勿重叠(重叠则 deny 先触发、mask 轮不到 → 白做):
|
|
21
|
+
// - mask(credential_masking,默认开启)管「可读但含机密」:.env / .ssh/id_* / *.pem / *.key…
|
|
22
|
+
// - deny 管「根本不该碰」且未被 mask 覆盖的文件:.git-credentials / .npmrc / .kube/config…
|
|
23
|
+
// permissionRules: { deny: ['Read(**/.git-credentials)', 'Read(**/.npmrc)'] },
|
|
19
24
|
providers: DEFAULT_PROVIDERS,
|
|
20
25
|
marketplace: {
|
|
21
26
|
strictKnownMarketplaces: [],
|
|
@@ -23,7 +28,7 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
23
28
|
},
|
|
24
29
|
features: {
|
|
25
30
|
mcp: { oauthEnabled: true },
|
|
26
|
-
context: {
|
|
31
|
+
context: { adaptiveThresholds: true },
|
|
27
32
|
},
|
|
28
33
|
crsi: {
|
|
29
34
|
ruleInjection: true,
|
package/src/config/loader.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
DEFAULT_BACKGROUND_AGENT_CONFIG,
|
|
28
28
|
DEFAULT_CROSS_SESSION_CONFIG,
|
|
29
29
|
} from './defaults'
|
|
30
|
+
import { getCredentialKey, encryptApiKey, decryptApiKey, ENC_PREFIX } from './credential-crypto'
|
|
30
31
|
|
|
31
32
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
32
33
|
const BACKUP_PREFIX = 'config.backup-'
|
|
@@ -294,6 +295,9 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
|
294
295
|
backupConfig(userConfigPath)
|
|
295
296
|
}
|
|
296
297
|
|
|
298
|
+
// ── Decrypt API keys at rest (enc:v1:) back to plaintext ──
|
|
299
|
+
decryptProviderApiKeys(config.providers)
|
|
300
|
+
|
|
297
301
|
return config
|
|
298
302
|
}
|
|
299
303
|
|
|
@@ -438,6 +442,58 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
|
|
|
438
442
|
return merged
|
|
439
443
|
}
|
|
440
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Decrypt any encrypted (`enc:v1:`) provider API keys in place, after config
|
|
447
|
+
* merge. Plaintext (legacy / env-template) values pass through untouched. On
|
|
448
|
+
* decrypt failure (missing/corrupt credential key) the key is cleared and a
|
|
449
|
+
* warning is written, so the provider surfaces "apiKey not set" rather than
|
|
450
|
+
* sending a garbage value.
|
|
451
|
+
*/
|
|
452
|
+
function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
|
|
453
|
+
if (!providers) return
|
|
454
|
+
const needsKey = providers.some((p) => p.apiKey.startsWith(ENC_PREFIX))
|
|
455
|
+
if (!needsKey) return
|
|
456
|
+
const key = getCredentialKey(MIPHAM_HOME)
|
|
457
|
+
for (const p of providers) {
|
|
458
|
+
if (!p.apiKey.startsWith(ENC_PREFIX)) continue
|
|
459
|
+
try {
|
|
460
|
+
p.apiKey = decryptApiKey(p.apiKey, key)
|
|
461
|
+
} catch (err: unknown) {
|
|
462
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
463
|
+
process.stderr.write(
|
|
464
|
+
`⚠ Mipham Code: failed to decrypt API key for "${p.id}" (credential key missing/corrupt?): ${msg}\n`,
|
|
465
|
+
)
|
|
466
|
+
p.apiKey = ''
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Read a single provider's API key from config.yml, decrypting it if stored
|
|
473
|
+
* encrypted. Returns null when the provider has no key or the key can't be
|
|
474
|
+
* decrypted. Used by `/keys view` to show the plaintext key on request.
|
|
475
|
+
*/
|
|
476
|
+
export function getProviderApiKey(providerId: string, cwd: string = process.cwd()): string | null {
|
|
477
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
478
|
+
const projectConfigPath = join(cwd, '.mipham', 'config.yml')
|
|
479
|
+
const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
if (!existsSync(configPath)) return null
|
|
483
|
+
const raw = readFileSync(configPath, 'utf-8')
|
|
484
|
+
const doc = (parseYaml(raw) as Record<string, unknown>) || {}
|
|
485
|
+
const providers = (doc.providers as Array<Record<string, unknown>>) || []
|
|
486
|
+
const p = providers.find((x) => x.id === providerId)
|
|
487
|
+
if (!p || typeof p.apiKey !== 'string') return null
|
|
488
|
+
if (!p.apiKey.startsWith(ENC_PREFIX)) return p.apiKey
|
|
489
|
+
return decryptApiKey(p.apiKey, getCredentialKey(MIPHAM_HOME))
|
|
490
|
+
} catch (err: unknown) {
|
|
491
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
492
|
+
process.stderr.write(`⚠ Mipham Code: failed to read API key for "${providerId}": ${msg}\n`)
|
|
493
|
+
return null
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
441
497
|
/**
|
|
442
498
|
* Persist an API key for a single provider to the user-level config.yml.
|
|
443
499
|
* Reads the existing YAML, updates/replaces the provider's apiKey field,
|
|
@@ -445,16 +501,10 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
|
|
|
445
501
|
*
|
|
446
502
|
* Returns true on success, false on failure.
|
|
447
503
|
*/
|
|
448
|
-
export function saveProviderApiKey(
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
): boolean {
|
|
453
|
-
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
454
|
-
const projectConfigPath = join(cwd, '.mipham', 'config.yml')
|
|
455
|
-
|
|
456
|
-
// Prefer user-level config; fall back to project-level if no user config exists.
|
|
457
|
-
const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
|
|
504
|
+
export function saveProviderApiKey(providerId: string, apiKey: string): boolean {
|
|
505
|
+
// API keys are user-level secrets — always persist to the user config, never
|
|
506
|
+
// the project config (which lives in the repo and could be committed).
|
|
507
|
+
const configPath = join(MIPHAM_HOME, 'config.yml')
|
|
458
508
|
|
|
459
509
|
try {
|
|
460
510
|
mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
|
|
@@ -464,19 +514,20 @@ export function saveProviderApiKey(
|
|
|
464
514
|
if (existsSync(configPath)) {
|
|
465
515
|
const raw = readFileSync(configPath, 'utf-8')
|
|
466
516
|
doc = (parseYaml(raw) as Record<string, unknown>) || {}
|
|
467
|
-
} else if (configPath === projectConfigPath) {
|
|
468
|
-
mkdirSync(join(cwd, '.mipham'), { recursive: true })
|
|
469
517
|
}
|
|
470
518
|
|
|
471
519
|
// Find and update the provider in the providers array
|
|
472
520
|
const providers = (doc.providers as Array<Record<string, unknown>>) || []
|
|
473
521
|
const idx = providers.findIndex((p) => p.id === providerId)
|
|
474
522
|
|
|
523
|
+
// Encrypt literal keys at rest (env-variable templates pass through).
|
|
524
|
+
const storedKey = encryptApiKey(apiKey, getCredentialKey(MIPHAM_HOME))
|
|
525
|
+
|
|
475
526
|
if (idx >= 0) {
|
|
476
|
-
providers[idx] = { ...providers[idx], apiKey }
|
|
527
|
+
providers[idx] = { ...providers[idx], apiKey: storedKey }
|
|
477
528
|
} else {
|
|
478
529
|
// Provider not in config — append it
|
|
479
|
-
providers.push({ id: providerId, apiKey })
|
|
530
|
+
providers.push({ id: providerId, apiKey: storedKey })
|
|
480
531
|
}
|
|
481
532
|
|
|
482
533
|
doc.providers = providers
|
package/src/core/auto-memory.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { MemoryManager, type MemoryEntry } from './memory/memory-manager.js'
|
|
15
15
|
import type { PatternAnalyzer, Pattern } from '../agent/pattern-analyzer.js'
|
|
16
|
-
import type { ExperienceRuleEngine
|
|
16
|
+
import type { ExperienceRuleEngine } from './rule-engine.js'
|
|
17
17
|
import type { EffectivenessTracker } from '../agent/effectiveness-tracker.js'
|
|
18
18
|
import type { ErrorSignatureDB } from './error-signature-db.js'
|
|
19
19
|
import type { CrsiProvenanceBridge } from '../agent/crsi-provenance-bridge.js'
|
|
@@ -152,7 +152,7 @@ export class AutoMemoryEngine {
|
|
|
152
152
|
modelId: string
|
|
153
153
|
turnDurationMs: number
|
|
154
154
|
}): TurnReflection {
|
|
155
|
-
const { sessionId, userMessage, assistantContent, toolCalls
|
|
155
|
+
const { sessionId, userMessage, assistantContent, toolCalls } = params
|
|
156
156
|
|
|
157
157
|
// 1. Categorize tool calls
|
|
158
158
|
const successes: string[] = []
|
|
@@ -345,7 +345,7 @@ export class AutoMemoryEngine {
|
|
|
345
345
|
* Only insights that are auto-applicable and have sufficient evidence
|
|
346
346
|
* (≥ CRSI_RULE_THRESHOLD similar failures) are converted to rules.
|
|
347
347
|
*/
|
|
348
|
-
private feedCrsiPipeline(
|
|
348
|
+
private feedCrsiPipeline(_reflection: TurnReflection): void {
|
|
349
349
|
if (!this.ruleEngine || !this.patternAnalyzer) return
|
|
350
350
|
|
|
351
351
|
// Count failures by category across ALL reflections (not just this one)
|
|
@@ -167,7 +167,6 @@ export class ConstitutionLoader {
|
|
|
167
167
|
const result: MiphamConstitution = { version: '0.0.0', last_modified: '', principles: [] }
|
|
168
168
|
let currentPrinciple: Partial<ConstitutionalPrinciple> | null = null
|
|
169
169
|
let inPrinciples = false
|
|
170
|
-
let inList = false
|
|
171
170
|
|
|
172
171
|
for (const line of lines) {
|
|
173
172
|
const trimmed = line.trim()
|
|
@@ -188,7 +187,6 @@ export class ConstitutionLoader {
|
|
|
188
187
|
|
|
189
188
|
// List items in principles
|
|
190
189
|
if (inPrinciples && trimmed === '- id:') {
|
|
191
|
-
inList = true
|
|
192
190
|
continue
|
|
193
191
|
}
|
|
194
192
|
|
package/src/core/context.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { MaskingPipeline, getPipeline, maskContent } from './pipeline'
|
|
|
5
5
|
export { matchCredentialFile, matchPath, globToRegex } from './matcher'
|
|
6
6
|
export { filterEnv } from './env-filter'
|
|
7
7
|
export { maskOutput } from './output-scrub'
|
|
8
|
+
export { maskSearchOutput, maskGlobOutput } from './search'
|
|
8
9
|
|
|
9
10
|
// Strategies
|
|
10
11
|
export { FullMaskingStrategy } from './strategies/full'
|
|
@@ -65,13 +65,16 @@ export function matchPath(filePath: string, pattern: string): boolean {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
* -
|
|
70
|
-
* -
|
|
71
|
-
*
|
|
72
|
-
* -
|
|
68
|
+
* Translate a glob pattern to an un-anchored RegExp source (no `^` or `$`).
|
|
69
|
+
* This is the shared path-glob core, reused by both credential-file matching
|
|
70
|
+
* (full-path anchored via `globToRegex`) and `.mipham/rules/` path scoping
|
|
71
|
+
* (suffix anchored in RulesLoader). Semantics:
|
|
72
|
+
* - ** → .* (matches any number of path segments, crosses `/`)
|
|
73
|
+
* - * → [^/]* (matches within a single segment, does not cross `/`)
|
|
74
|
+
* - ? → . (single char)
|
|
75
|
+
* - regex specials are escaped (treated literally)
|
|
73
76
|
*/
|
|
74
|
-
export function
|
|
77
|
+
export function globToRegexSource(pattern: string): string {
|
|
75
78
|
let regexStr = ''
|
|
76
79
|
let i = 0
|
|
77
80
|
while (i < pattern.length) {
|
|
@@ -102,5 +105,13 @@ export function globToRegex(pattern: string): RegExp {
|
|
|
102
105
|
}
|
|
103
106
|
i++
|
|
104
107
|
}
|
|
105
|
-
return
|
|
108
|
+
return regexStr
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convert a glob pattern to a full-path-anchored RegExp (see `globToRegexSource`
|
|
113
|
+
* for the `*`/`**`/`?` translation and escaping rules).
|
|
114
|
+
*/
|
|
115
|
+
export function globToRegex(pattern: string): RegExp {
|
|
116
|
+
return new RegExp('^' + globToRegexSource(pattern) + '$')
|
|
106
117
|
}
|