@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.
- package/bin/mipham.ts +1 -9
- package/package.json +1 -1
- 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/project.ts +1 -1
- package/src/config/defaults.ts +5 -0
- 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/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 +6 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +2 -0
- package/src/skills/bundled-skill-assets.ts +5 -5
- package/src/skills/bundled-skills.ts +1 -1
- package/src/tools/agent/exit-plan.ts +1 -3
- package/src/tools/agent/memory.ts +2 -1
- 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 +188 -119
- 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 {
|
|
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
|
@@ -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/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()
|
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: [],
|
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
|
}
|
|
@@ -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
|
|
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -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
|
|
package/src/core/dream-engine.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/src/core/rules-loader.ts
CHANGED
|
@@ -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
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
}
|
package/src/daemon/database.ts
CHANGED
|
@@ -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.
|
|
31
|
+
const session = deps.sm.getOrCreateByExternalUser(
|
|
32
|
+
'feishu',
|
|
32
33
|
msg.openId,
|
|
33
34
|
deps.cwd,
|
|
34
35
|
deps.provider,
|