@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.
Files changed (57) hide show
  1. package/bin/mipham.ts +1 -9
  2. package/package.json +1 -2
  3. package/skills/standard/web-access/references/cdp-api.md +2 -1
  4. package/skills/standard/web-access/scripts/cdp-proxy.mjs +75 -1
  5. package/skills/standard/web-access/scripts/check-deps.mjs +17 -2
  6. package/skills/standard/web-access/scripts/find-url.mjs +0 -0
  7. package/skills/standard/web-access/scripts/match-site.mjs +0 -0
  8. package/skills/standard/web-access.SKILL.md +10 -3
  9. package/src/commands/keys.ts +26 -0
  10. package/src/commands/project.ts +1 -1
  11. package/src/config/credential-crypto.ts +114 -0
  12. package/src/config/defaults.ts +6 -1
  13. package/src/config/loader.ts +65 -14
  14. package/src/core/auto-memory.ts +3 -3
  15. package/src/core/constitution-loader.ts +0 -2
  16. package/src/core/context.ts +0 -1
  17. package/src/core/credential-masker/index.ts +1 -0
  18. package/src/core/credential-masker/matcher.ts +18 -7
  19. package/src/core/credential-masker/search.ts +73 -0
  20. package/src/core/credential-masker.ts +1 -0
  21. package/src/core/crsi-sandbox.ts +0 -1
  22. package/src/core/dream-engine.ts +0 -2
  23. package/src/core/instructions.ts +36 -12
  24. package/src/core/memory/memory-loader.ts +3 -1
  25. package/src/core/permission-rules.ts +23 -12
  26. package/src/core/rules-loader.ts +8 -10
  27. package/src/daemon/database.ts +0 -1
  28. package/src/daemon/feishu/adapter.ts +2 -1
  29. package/src/daemon/index.ts +18 -8
  30. package/src/daemon/server.ts +53 -10
  31. package/src/daemon/session-manager.ts +5 -4
  32. package/src/daemon/telegram/adapter.ts +70 -0
  33. package/src/daemon/telegram/api.ts +45 -0
  34. package/src/daemon/telegram/env.ts +14 -0
  35. package/src/daemon/telegram/poller.ts +64 -0
  36. package/src/daemon/telegram/types.ts +10 -0
  37. package/src/i18n-core/locales/en-US.json +123 -0
  38. package/src/i18n-core/locales/zh-CN.json +123 -0
  39. package/src/index.tsx +10 -8
  40. package/src/mcp/token-store.ts +2 -37
  41. package/src/shared/package-info.ts +1 -1
  42. package/src/shared/types.ts +5 -3
  43. package/src/skills/bin-check.ts +40 -0
  44. package/src/skills/bundled-skill-assets.ts +5 -5
  45. package/src/skills/bundled-skills.ts +1 -1
  46. package/src/skills/loader.ts +35 -10
  47. package/src/skills/sanitizer.ts +1 -2
  48. package/src/skills/seam.ts +1 -1
  49. package/src/tools/agent/exit-plan.ts +1 -3
  50. package/src/tools/agent/memory.ts +2 -1
  51. package/src/tools/agent/skill.ts +14 -0
  52. package/src/tools/file/glob.ts +38 -23
  53. package/src/tools/file/grep.ts +86 -64
  54. package/src/tools/index.ts +5 -5
  55. package/src/ui/commands.ts +191 -119
  56. package/src/ui/config-wizard.tsx +5 -2
  57. 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 { readFileSync, existsSync } = await import('node:fs')
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.50.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 ~/.claude/skills/web-access/scripts/cdp-proxy.mjs &`
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
- return fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
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 API(curl 调 `http://localhost:3456/...`):
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,不暴露外网;端口 3456 无鉴权,依赖本机信任边界,勿在共享/多用户主机运行。
126
- - ⚠️ proxy 不做 URL SSRF 校验(照搬上游,与 computer-use/Playwright 同类):避免驱动 Chrome 访问本机内部服务/内网地址。
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
 
@@ -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
 
@@ -1015,7 +1015,7 @@ const securityCmd: CommandHandler = async () => {
1015
1015
  return { content: lines.join('\n') }
1016
1016
  }
1017
1017
 
1018
- const trustCmd: CommandHandler = (ctx) => {
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
+ }
@@ -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: { useRealTokenizer: true, adaptiveThresholds: true },
31
+ context: { adaptiveThresholds: true },
27
32
  },
28
33
  crsi: {
29
34
  ruleInjection: true,
@@ -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
- providerId: string,
450
- apiKey: string,
451
- cwd: string = process.cwd(),
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
@@ -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, ToolRule } from './rule-engine.js'
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, modelProvider, modelId } = params
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(reflection: TurnReflection): void {
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
 
@@ -186,7 +186,6 @@ export class ContextManager {
186
186
  }
187
187
 
188
188
  async compact(heading: string): Promise<{ before: number; after: number }> {
189
- const beforeMsgs = this.messages.length
190
189
  const beforeTokens = this.estimatedTokens
191
190
 
192
191
  if (this.messages.length <= 30) {
@@ -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
- * Convert a glob pattern to a RegExp.
69
- * - ** → .* (matches any path segments)
70
- * - * → [^/]* (matches within a single segment)
71
- * - ? → . (single char)
72
- * - All other special regex chars are escaped.
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 globToRegex(pattern: string): RegExp {
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 new RegExp('^' + regexStr + '$')
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
  }