@miphamai/cli 0.50.0 → 0.51.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 (46) hide show
  1. package/bin/mipham.ts +1 -9
  2. package/package.json +1 -1
  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/project.ts +1 -1
  10. package/src/config/defaults.ts +5 -0
  11. package/src/core/auto-memory.ts +3 -3
  12. package/src/core/constitution-loader.ts +0 -2
  13. package/src/core/context.ts +0 -1
  14. package/src/core/credential-masker/index.ts +1 -0
  15. package/src/core/credential-masker/matcher.ts +18 -7
  16. package/src/core/credential-masker/search.ts +73 -0
  17. package/src/core/credential-masker.ts +1 -0
  18. package/src/core/crsi-sandbox.ts +0 -1
  19. package/src/core/dream-engine.ts +0 -2
  20. package/src/core/memory/memory-loader.ts +3 -1
  21. package/src/core/permission-rules.ts +23 -12
  22. package/src/core/rules-loader.ts +8 -10
  23. package/src/daemon/database.ts +0 -1
  24. package/src/daemon/feishu/adapter.ts +2 -1
  25. package/src/daemon/index.ts +18 -8
  26. package/src/daemon/server.ts +53 -10
  27. package/src/daemon/session-manager.ts +5 -4
  28. package/src/daemon/telegram/adapter.ts +70 -0
  29. package/src/daemon/telegram/api.ts +45 -0
  30. package/src/daemon/telegram/env.ts +14 -0
  31. package/src/daemon/telegram/poller.ts +64 -0
  32. package/src/daemon/telegram/types.ts +10 -0
  33. package/src/i18n-core/locales/en-US.json +123 -0
  34. package/src/i18n-core/locales/zh-CN.json +123 -0
  35. package/src/index.tsx +6 -0
  36. package/src/shared/package-info.ts +1 -1
  37. package/src/shared/types.ts +2 -0
  38. package/src/skills/bundled-skill-assets.ts +5 -5
  39. package/src/skills/bundled-skills.ts +1 -1
  40. package/src/tools/agent/exit-plan.ts +1 -3
  41. package/src/tools/agent/memory.ts +2 -1
  42. package/src/tools/file/glob.ts +38 -23
  43. package/src/tools/file/grep.ts +86 -64
  44. package/src/tools/index.ts +5 -5
  45. package/src/ui/commands.ts +188 -119
  46. package/src/ui/config-wizard.tsx +1 -1
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.51.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
 
@@ -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()
@@ -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: [],
@@ -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
  }
@@ -0,0 +1,73 @@
1
+ import type { CredentialMaskingConfig } from '../../shared/index.ts'
2
+ import { matchCredentialFile } from './matcher'
3
+ import { CREDENTIAL_SENTINEL } from './types'
4
+
5
+ /**
6
+ * Mask grep/search output: any match line that belongs to a credential file
7
+ * (e.g. `.env`, `.aws/credentials`) has its content replaced with the
8
+ * credential sentinel, so Grep can't leak secrets that Read would mask.
9
+ *
10
+ * `format` disambiguates the two shapes the Grep tool emits — each invocation
11
+ * produces exactly one, so there is no cross-format ambiguity to guess at:
12
+ * - 'heading' (ripgrep `--heading`): file path on its own line, then `N:content` lines
13
+ * - 'filename' (plain grep `-rn`): `path:N:content` on a single line
14
+ *
15
+ * NOTE: the sensitive file's *path* stays visible — only its match content is
16
+ * masked. That's intentional for grep (a targeted search where the model already
17
+ * named the path); the secret is the content, not the location. Contrast
18
+ * `maskGlobOutput`, which hides the path entirely (enumeration's leak is existence).
19
+ */
20
+ export function maskSearchOutput(
21
+ stdout: string,
22
+ config?: CredentialMaskingConfig,
23
+ format: 'heading' | 'filename' = 'heading',
24
+ ): string {
25
+ if (!config?.enabled) return stdout
26
+ const out: string[] = []
27
+ let currentMasked = false
28
+
29
+ for (const line of stdout.split('\n')) {
30
+ if (format === 'filename') {
31
+ const m = line.match(/^(.+?):(\d+):(.*)$/)
32
+ if (m) {
33
+ out.push(
34
+ matchCredentialFile(m[1]!, config) ? `${m[1]}:${m[2]}:${CREDENTIAL_SENTINEL}` : line,
35
+ )
36
+ } else {
37
+ out.push(line)
38
+ }
39
+ continue
40
+ }
41
+
42
+ // 'heading' format: match lines are `N:content`; everything non-empty else is a file heading.
43
+ const m = line.match(/^(\d+):(.*)$/)
44
+ if (m) {
45
+ out.push(currentMasked ? `${m[1]}:${CREDENTIAL_SENTINEL}` : line)
46
+ continue
47
+ }
48
+ if (line.trim() === '') {
49
+ out.push(line) // blank separator — pass through, keep current file
50
+ continue
51
+ }
52
+ currentMasked = !!matchCredentialFile(line, config)
53
+ out.push(line)
54
+ }
55
+
56
+ return out.join('\n')
57
+ }
58
+
59
+ /**
60
+ * Mask a glob file listing: sensitive file paths are replaced with the
61
+ * credential sentinel so file enumeration can't reveal their existence.
62
+ *
63
+ * NOTE: unlike `maskSearchOutput` (which keeps the searched path visible and
64
+ * masks only content), glob hides the whole path — here the leak is the file's
65
+ * *existence*, not its content.
66
+ */
67
+ export function maskGlobOutput(paths: string, config?: CredentialMaskingConfig): string {
68
+ if (!config?.enabled) return paths
69
+ return paths
70
+ .split('\n')
71
+ .map((p) => (matchCredentialFile(p, config) ? CREDENTIAL_SENTINEL : p))
72
+ .join('\n')
73
+ }
@@ -13,6 +13,7 @@ export { matchCredentialFile } from './credential-masker/matcher'
13
13
  export { maskContent } from './credential-masker/pipeline'
14
14
  export { maskOutput } from './credential-masker/output-scrub'
15
15
  export { filterEnv } from './credential-masker/env-filter'
16
+ export { maskSearchOutput, maskGlobOutput } from './credential-masker/search'
16
17
 
17
18
  import { CREDENTIAL_SENTINEL } from './credential-masker/types'
18
19
 
@@ -84,7 +84,6 @@ export interface CrsiSessionReport {
84
84
  // ── Constants ──
85
85
 
86
86
  const WORKTREE_PREFIX = 'crsi-sandbox-'
87
- const MAX_WORKTREE_AGE_MS = 30 * 60 * 1000 // 30 minutes
88
87
  const TEST_TIMEOUT_MS = 120_000 // 2 minutes
89
88
  const REPORT_DIR = join(homedir(), '.mipham', 'crsi-sandbox')
90
89
 
@@ -469,7 +469,6 @@ export class DreamEngine {
469
469
  const lines = match[1]!.split('\n')
470
470
  const fm: Record<string, unknown> = {}
471
471
  let currentNested: Record<string, unknown> | null = null
472
- let nestedKey = ''
473
472
  for (const line of lines) {
474
473
  // Handle nested YAML (e.g. "metadata:\n type: feedback")
475
474
  const nested = line.match(/^ (\w[\w\s]*?):\s*(.+)$/)
@@ -485,7 +484,6 @@ export class DreamEngine {
485
484
  if (val === '') {
486
485
  // Empty value → start nested block
487
486
  currentNested = {}
488
- nestedKey = key
489
487
  fm[key] = currentNested
490
488
  } else {
491
489
  currentNested = null
@@ -1,7 +1,9 @@
1
+ import { join } from 'node:path'
2
+ import { homedir } from 'node:os'
1
3
  import { MemoryManager } from './memory-manager'
2
4
  import type { MemoryManager as MemoryManagerType } from './memory-manager'
3
5
 
4
- const MEMORY_DIR = `${process.env.HOME || '~'}/.mipham/memory`
6
+ const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
5
7
 
6
8
  let instance: MemoryManagerType | null = null
7
9
 
@@ -1,14 +1,16 @@
1
1
  import type { PermissionRuleEntry } from '../shared/index.ts'
2
+ import { matchPath } from './credential-masker/matcher'
2
3
 
3
- /**
4
- * Match a Bash(command_pattern) rule against an actual tool call.
5
- *
6
- * Pattern formats:
7
- * "Bash" → matches any Bash call
8
- * "Bash(git:*)" → matches "git status", "git diff --cached", etc.
9
- * "Bash(npm test:*)" → matches "npm test -- --coverage"
10
- * "Write(/etc/*)" → matches Write to /etc/passwd, /etc/hosts, etc.
11
- */
4
+ // Match a tool(parameter) rule against an actual tool call.
5
+ //
6
+ // Pattern formats:
7
+ // "Bash" → matches any Bash call
8
+ // "Bash(git:*)" → matches "git status", "git diff --cached", etc.
9
+ // "Bash(npm test:*)" → matches "npm test -- --coverage"
10
+ // "Write(/etc/*)" → matches Write to /etc/passwd, /etc/hosts, etc.
11
+ // "Read(**/.ssh/*)" → matches Read of any path under .ssh
12
+ // "Grep(**/vendor)" → matches Grep rooted under a vendor directory
13
+ // "Glob(**/.ssh)" → matches Glob rooted under a .ssh directory
12
14
  export function matchBashRule(
13
15
  pattern: string,
14
16
  toolName: string,
@@ -31,10 +33,19 @@ export function matchBashRule(
31
33
  return wildcardMatch(subPattern!, cmd.trim())
32
34
  }
33
35
 
34
- // For Write/Edit: match against the file_path
35
- if (baseTool === 'Write' || baseTool === 'Edit') {
36
+ // For Write/Edit/Read: match against the file_path with path-glob semantics.
37
+ // Use matchPath (NOT wildcardMatch): wildcardMatch is tuned for Bash commands
38
+ // (`:` → colon-or-whitespace, `*` → `.*`), which is wrong for filesystem
39
+ // paths — `*` would cross `/` and Windows drive letters like `C:\` get mangled.
40
+ if (baseTool === 'Write' || baseTool === 'Edit' || baseTool === 'Read') {
36
41
  const path = String(toolInput.file_path || '')
37
- return wildcardMatch(subPattern!, path)
42
+ return matchPath(path, subPattern!)
43
+ }
44
+
45
+ // For Grep/Glob: match against the base search path (a directory)
46
+ if (baseTool === 'Grep' || baseTool === 'Glob') {
47
+ const path = String(toolInput.path || '')
48
+ return matchPath(path, subPattern!)
38
49
  }
39
50
 
40
51
  return false
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { readdirSync, readFileSync, existsSync } from 'node:fs'
21
21
  import { join } from 'node:path'
22
+ import { globToRegexSource } from './credential-masker/matcher'
22
23
 
23
24
  interface RuleFile {
24
25
  name: string
@@ -117,18 +118,15 @@ export class RulesLoader {
117
118
  }
118
119
 
119
120
  /**
120
- * Simple glob matching. Supports **, *, and exact file matching.
121
- * Returns true if file matches pattern.
121
+ * Glob matching for path-scoped rules. Reuses the shared path-glob core
122
+ * (globToRegexSource) so `*`/`**`/`?` semantics match credential-file
123
+ * matching. Anchoring differs: rules match by *suffix* (a rule `*.ts`
124
+ * applies to any file ending in `.ts`), not full path.
122
125
  */
123
126
  private matchPath(file: string, pattern: string): boolean {
124
- // Convert glob pattern to regex
125
- let regexStr = pattern
126
- .replace(/\./g, '\\.')
127
- .replace(/\*\*/g, '<<GLOBSTAR>>')
128
- .replace(/\*/g, '[^/]*')
129
- .replace(/<<GLOBSTAR>>/g, '.*')
130
-
131
- // If pattern doesn't start with ** or *, anchor to be a suffix match
127
+ let regexStr = globToRegexSource(pattern)
128
+
129
+ // If pattern doesn't start with ** or *, anchor to a suffix match
132
130
  if (!pattern.startsWith('**') && !pattern.startsWith('*')) {
133
131
  regexStr = regexStr + '$'
134
132
  }
@@ -289,7 +289,6 @@ export class DaemonDatabase {
289
289
  }
290
290
 
291
291
  vals.push(id)
292
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
292
  this.db.run(`UPDATE goals SET ${sets.join(', ')} WHERE id = ?`, vals as any)
294
293
  }
295
294
 
@@ -28,7 +28,8 @@ export function createFeishuAdapter(config: FeishuConfig, deps: FeishuAdapterDep
28
28
  if (!allowed.has(msg.openId)) return
29
29
  if (!deps.rateLimiter.check(`feishu:${msg.openId}`).allowed) return
30
30
 
31
- const session = deps.sm.getOrCreateByFeishuOpenId(
31
+ const session = deps.sm.getOrCreateByExternalUser(
32
+ 'feishu',
32
33
  msg.openId,
33
34
  deps.cwd,
34
35
  deps.provider,