@miphamai/cli 0.22.0 → 0.23.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/README.md +1 -1
- package/package.json +1 -1
- package/skills/workflows/audit.js +66 -18
- package/src/agent/cross-session/discovery.ts +99 -0
- package/src/agent/cross-session/file-inbox.ts +118 -0
- package/src/agent/cross-session/transport.ts +17 -0
- package/src/agent/message-router.ts +83 -0
- package/src/config/defaults.ts +6 -0
- package/src/config/loader.ts +33 -0
- package/src/core/credential-masker/env-filter.ts +39 -0
- package/src/core/credential-masker/index.ts +35 -0
- package/src/core/credential-masker/matcher.ts +106 -0
- package/src/core/credential-masker/output-scrub.ts +27 -0
- package/src/core/credential-masker/pipeline.ts +67 -0
- package/src/core/credential-masker/strategies/aws.ts +50 -0
- package/src/core/credential-masker/strategies/extract.ts +106 -0
- package/src/core/credential-masker/strategies/full.ts +15 -0
- package/src/core/credential-masker/strategies/jwt.ts +75 -0
- package/src/core/credential-masker/types.ts +16 -0
- package/src/core/credential-masker.ts +12 -187
- package/src/core/engine.ts +44 -9
- package/src/core/session-store.ts +15 -2
- package/src/index.tsx +44 -1
- package/src/shared/types.ts +55 -5
- package/src/tools/agent/list-agents.ts +67 -0
- package/src/tools/agent/send-message.ts +21 -24
- package/src/tools/exec/bash.ts +67 -3
- package/src/tools/index.ts +2 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
18
18
|
- **9 AI Providers** — Anthropic Claude · OpenAI GPT · DeepSeek · Kimi (Moonshot) · Google Gemini · Doubao 豆包 · Tencent Hunyuan 混元 · Qwen 通义千问 · MiphamAI
|
|
19
|
-
- **66 Slash Commands** — Interactive command picker (↑↓ Enter Esc),
|
|
19
|
+
- **66 Slash Commands** — Interactive command picker (↑↓ Enter Esc), intuitive command interface, zero re-learning
|
|
20
20
|
- **16 Built-in Tools** — File ops, shell execution, agent dispatch, MCP integration, web search
|
|
21
21
|
- **14 Skills + Marketplace** — 11 standard + 3 Mipham-exclusive skills. `/browse-skills` `/install-skill` community marketplace
|
|
22
22
|
- **Self-Update** — `mipham update` / `/upgrade` — one command to check, backup config, upgrade, restore. API keys preserved
|
package/package.json
CHANGED
|
@@ -10,37 +10,85 @@ export const meta = {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
phase('Scope')
|
|
13
|
-
const targets =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
const targets =
|
|
14
|
+
args.targets ||
|
|
15
|
+
(
|
|
16
|
+
await agent(
|
|
17
|
+
'List all source files that need security auditing. Return { files: [{ path, reason }] }',
|
|
18
|
+
{
|
|
19
|
+
schema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
files: {
|
|
23
|
+
type: 'array',
|
|
24
|
+
items: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: { path: { type: 'string' }, reason: { type: 'string' } },
|
|
27
|
+
required: ['path', 'reason'],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ['files'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
).files
|
|
17
36
|
|
|
18
37
|
log(`Auditing ${targets.length} files`)
|
|
19
38
|
|
|
20
39
|
phase('Audit')
|
|
21
|
-
const raw =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
{
|
|
25
|
-
|
|
40
|
+
const raw = await pipeline(targets, (t) =>
|
|
41
|
+
agent(
|
|
42
|
+
`Security audit ${t.path}: injection, auth, crypto, secrets, input validation. Return { findings: [{ severity, file, line, summary }] }`,
|
|
43
|
+
{
|
|
44
|
+
label: `audit:${t.path}`,
|
|
45
|
+
schema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
findings: {
|
|
49
|
+
type: 'array',
|
|
50
|
+
items: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
54
|
+
file: { type: 'string' },
|
|
55
|
+
line: { type: 'number' },
|
|
56
|
+
summary: { type: 'string' },
|
|
57
|
+
},
|
|
58
|
+
required: ['severity', 'file', 'summary'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
required: ['findings'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
),
|
|
26
66
|
)
|
|
27
|
-
const findings = raw.flatMap(r => (r && r.findings) || [])
|
|
67
|
+
const findings = raw.flatMap((r) => (r && r.findings) || [])
|
|
28
68
|
|
|
29
69
|
log(`Found ${findings.length} potential issues`)
|
|
30
70
|
|
|
31
71
|
phase('Verify')
|
|
32
72
|
const verified = await parallel(
|
|
33
|
-
findings.map(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
findings.map(
|
|
74
|
+
(f) => () =>
|
|
75
|
+
verify(f, {
|
|
76
|
+
mode: 'adversarial',
|
|
77
|
+
skeptics: 3,
|
|
78
|
+
threshold: 2,
|
|
79
|
+
schema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: { real: { type: 'boolean' }, reason: { type: 'string' } },
|
|
82
|
+
required: ['real', 'reason'],
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
40
85
|
),
|
|
41
86
|
)
|
|
42
87
|
|
|
43
|
-
const confirmed = verified
|
|
88
|
+
const confirmed = verified
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.filter((v) => v.survives)
|
|
91
|
+
.map((v) => v.finding)
|
|
44
92
|
log(`${confirmed.length}/${findings.length} findings verified`)
|
|
45
93
|
|
|
46
94
|
phase('Report')
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readdirSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { homedir, hostname } from 'node:os'
|
|
11
|
+
import type { SessionInfo } from '../../shared/types'
|
|
12
|
+
|
|
13
|
+
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
14
|
+
const ACTIVE_SESSIONS_DIR = join(MIPHAM_HOME, '.active-sessions')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register the current session as active.
|
|
18
|
+
* Writes a SessionInfo JSON file + updates mtime for heartbeat.
|
|
19
|
+
*/
|
|
20
|
+
export function registerActiveSession(info: SessionInfo): void {
|
|
21
|
+
mkdirSync(ACTIVE_SESSIONS_DIR, { recursive: true })
|
|
22
|
+
const filePath = join(ACTIVE_SESSIONS_DIR, `${info.id}.json`)
|
|
23
|
+
writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Update heartbeat (mtime) for an active session.
|
|
28
|
+
*/
|
|
29
|
+
export function heartbeatSession(sessionId: string): void {
|
|
30
|
+
const filePath = join(ACTIVE_SESSIONS_DIR, `${sessionId}.json`)
|
|
31
|
+
if (existsSync(filePath)) {
|
|
32
|
+
// Touch the file by rewriting it
|
|
33
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
34
|
+
writeFileSync(filePath, raw, 'utf-8') // updates mtime
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Unregister a session (called on shutdown).
|
|
40
|
+
*/
|
|
41
|
+
export function unregisterSession(sessionId: string): void {
|
|
42
|
+
const filePath = join(ACTIVE_SESSIONS_DIR, `${sessionId}.json`)
|
|
43
|
+
try {
|
|
44
|
+
if (existsSync(filePath)) unlinkSync(filePath)
|
|
45
|
+
} catch {
|
|
46
|
+
// Best-effort
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Discover all active sessions.
|
|
52
|
+
* Reads ~/.mipham/.active-sessions/ directory.
|
|
53
|
+
*/
|
|
54
|
+
export function discoverSessions(): SessionInfo[] {
|
|
55
|
+
if (!existsSync(ACTIVE_SESSIONS_DIR)) return []
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const files = readdirSync(ACTIVE_SESSIONS_DIR).filter((f) => f.endsWith('.json'))
|
|
59
|
+
|
|
60
|
+
const sessions: SessionInfo[] = []
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
try {
|
|
63
|
+
const raw = readFileSync(join(ACTIVE_SESSIONS_DIR, file), 'utf-8')
|
|
64
|
+
const info = JSON.parse(raw) as SessionInfo
|
|
65
|
+
sessions.push(info)
|
|
66
|
+
} catch {
|
|
67
|
+
// Skip corrupt files
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Sort by most recently active
|
|
72
|
+
sessions.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
|
|
73
|
+
return sessions
|
|
74
|
+
} catch {
|
|
75
|
+
return []
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a SessionInfo for the current process.
|
|
81
|
+
*/
|
|
82
|
+
export function createSessionInfo(
|
|
83
|
+
sessionId: string,
|
|
84
|
+
name: string,
|
|
85
|
+
cwd?: string,
|
|
86
|
+
provider?: string,
|
|
87
|
+
model?: string,
|
|
88
|
+
): SessionInfo {
|
|
89
|
+
return {
|
|
90
|
+
id: sessionId,
|
|
91
|
+
name,
|
|
92
|
+
machine: hostname(),
|
|
93
|
+
pid: process.pid,
|
|
94
|
+
startedAt: new Date().toISOString(),
|
|
95
|
+
cwd,
|
|
96
|
+
provider,
|
|
97
|
+
model,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import type { CrossSessionTransport } from './transport'
|
|
13
|
+
import type { AgentMessage } from '../message-bus'
|
|
14
|
+
import type { SessionInfo } from '../../shared/types'
|
|
15
|
+
|
|
16
|
+
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
17
|
+
const INBOX_DIR = join(MIPHAM_HOME, 'inbox')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Filesystem-based cross-session transport.
|
|
21
|
+
*
|
|
22
|
+
* Messages are stored as JSON files in ~/.mipham/inbox/<session-id>/.
|
|
23
|
+
* Each message file is named: <ISO-timestamp>-<msg-id>.json
|
|
24
|
+
*/
|
|
25
|
+
export class FileInboxTransport implements CrossSessionTransport {
|
|
26
|
+
private ensureDir(dir: string): void {
|
|
27
|
+
mkdirSync(dir, { recursive: true })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async send(from: SessionInfo, toSessionId: string, msg: AgentMessage): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
const inboxDir = join(INBOX_DIR, toSessionId)
|
|
33
|
+
this.ensureDir(inboxDir)
|
|
34
|
+
|
|
35
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
36
|
+
const filename = `${timestamp}-${msg.id}.json`
|
|
37
|
+
const filePath = join(inboxDir, filename)
|
|
38
|
+
|
|
39
|
+
// Atomic write: temp file then rename
|
|
40
|
+
const tmpPath = filePath + '.tmp'
|
|
41
|
+
writeFileSync(
|
|
42
|
+
tmpPath,
|
|
43
|
+
JSON.stringify({ ...msg, _fromSession: from.id, _fromMachine: from.machine }, null, 2),
|
|
44
|
+
'utf-8',
|
|
45
|
+
)
|
|
46
|
+
renameSync(tmpPath, filePath)
|
|
47
|
+
|
|
48
|
+
return true
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async poll(sessionId: string): Promise<AgentMessage[]> {
|
|
55
|
+
const inboxDir = join(INBOX_DIR, sessionId)
|
|
56
|
+
if (!existsSync(inboxDir)) return []
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const files = readdirSync(inboxDir)
|
|
60
|
+
.filter((f) => f.endsWith('.json') && !f.endsWith('.tmp'))
|
|
61
|
+
.sort() // chronological by timestamp prefix
|
|
62
|
+
|
|
63
|
+
const messages: AgentMessage[] = []
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
try {
|
|
66
|
+
const raw = readFileSync(join(inboxDir, file), 'utf-8')
|
|
67
|
+
const parsed = JSON.parse(raw) as AgentMessage & {
|
|
68
|
+
_fromSession?: string
|
|
69
|
+
_fromMachine?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build the message body with cross-session origin prefix
|
|
73
|
+
const originPrefix = parsed._fromSession
|
|
74
|
+
? `[From: ${parsed._fromSession}@${parsed._fromMachine || 'unknown'}]\n\n`
|
|
75
|
+
: ''
|
|
76
|
+
|
|
77
|
+
messages.push({
|
|
78
|
+
id: parsed.id,
|
|
79
|
+
from: parsed.from,
|
|
80
|
+
to: parsed.to,
|
|
81
|
+
summary: parsed.summary,
|
|
82
|
+
message: `${originPrefix}${parsed.message}`,
|
|
83
|
+
timestamp: new Date(parsed.timestamp),
|
|
84
|
+
read: parsed.read,
|
|
85
|
+
type: parsed.type,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Mark as read by deleting the file (read-once)
|
|
89
|
+
unlinkSync(join(inboxDir, file))
|
|
90
|
+
} catch {
|
|
91
|
+
// Skip corrupt files
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return messages
|
|
96
|
+
} catch {
|
|
97
|
+
return []
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async listSessions(): Promise<SessionInfo[]> {
|
|
102
|
+
return discoverSessions()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Import discoverSessions lazily to avoid circular dependency
|
|
107
|
+
import { discoverSessions } from './discovery'
|
|
108
|
+
|
|
109
|
+
// ── Singleton ──
|
|
110
|
+
|
|
111
|
+
let _transport: FileInboxTransport | null = null
|
|
112
|
+
|
|
113
|
+
export function getFileInboxTransport(): FileInboxTransport {
|
|
114
|
+
if (!_transport) {
|
|
115
|
+
_transport = new FileInboxTransport()
|
|
116
|
+
}
|
|
117
|
+
return _transport
|
|
118
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentMessage } from '../message-bus'
|
|
2
|
+
import type { SessionInfo } from '../../shared/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transport layer for cross-session messaging.
|
|
6
|
+
* Implementations can use filesystem, unix sockets, TCP, etc.
|
|
7
|
+
*/
|
|
8
|
+
export interface CrossSessionTransport {
|
|
9
|
+
/** Send a message to a destination session's inbox. Returns true on success. */
|
|
10
|
+
send(from: SessionInfo, toSessionId: string, msg: AgentMessage): Promise<boolean>
|
|
11
|
+
|
|
12
|
+
/** Poll for unread messages addressed to the given session. */
|
|
13
|
+
poll(sessionId: string): Promise<AgentMessage[]>
|
|
14
|
+
|
|
15
|
+
/** List all discoverable active sessions. */
|
|
16
|
+
listSessions(): Promise<SessionInfo[]>
|
|
17
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getMessageBus } from './message-bus'
|
|
2
|
+
import { getFileInboxTransport } from './cross-session/file-inbox'
|
|
3
|
+
import { discoverSessions, createSessionInfo } from './cross-session/discovery'
|
|
4
|
+
import type { AgentMessage } from './message-bus'
|
|
5
|
+
|
|
6
|
+
export interface RouteResult {
|
|
7
|
+
success: boolean
|
|
8
|
+
routedTo: 'bus' | 'inbox' | 'unknown'
|
|
9
|
+
error?: string
|
|
10
|
+
messageId?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* MessageRouter decides how to deliver a message based on the recipient.
|
|
15
|
+
*
|
|
16
|
+
* - "main" or background task IDs → in-memory AgentMessageBus (same process)
|
|
17
|
+
* - Session IDs matching discoverable sessions → file inbox (cross-session)
|
|
18
|
+
* - Unknown → error
|
|
19
|
+
*/
|
|
20
|
+
export class MessageRouter {
|
|
21
|
+
/**
|
|
22
|
+
* Route a message to the appropriate transport.
|
|
23
|
+
*/
|
|
24
|
+
async route(from: string, to: string, summary: string, message: string): Promise<RouteResult> {
|
|
25
|
+
// Same-process routing: "main" or background task IDs
|
|
26
|
+
if (to === 'main' || to.startsWith('bg-') || to.startsWith('sub-agent-')) {
|
|
27
|
+
try {
|
|
28
|
+
const bus = getMessageBus()
|
|
29
|
+
const msgId = bus.post(from, to, summary, message)
|
|
30
|
+
return { success: true, routedTo: 'bus', messageId: msgId }
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return { success: false, routedTo: 'bus', error: String(err) }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Cross-session routing
|
|
37
|
+
const sessions = discoverSessions()
|
|
38
|
+
const targetSession = sessions.find((s) => s.id === to)
|
|
39
|
+
|
|
40
|
+
if (!targetSession) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
routedTo: 'unknown',
|
|
44
|
+
error: `No active session found with ID "${to}". Use ListAgents to discover available sessions.`,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get sender info
|
|
49
|
+
const senderSession = createSessionInfo(from, from)
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const transport = getFileInboxTransport()
|
|
53
|
+
const msg: AgentMessage = {
|
|
54
|
+
id: `xmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
55
|
+
from,
|
|
56
|
+
to,
|
|
57
|
+
summary: summary.slice(0, 200),
|
|
58
|
+
message,
|
|
59
|
+
timestamp: new Date(),
|
|
60
|
+
read: false,
|
|
61
|
+
type: 'message',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const delivered = await transport.send(senderSession, to, msg)
|
|
65
|
+
if (!delivered) {
|
|
66
|
+
return { success: false, routedTo: 'inbox', error: 'Failed to write message to inbox.' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { success: true, routedTo: 'inbox', messageId: msg.id }
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { success: false, routedTo: 'inbox', error: String(err) }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let _router: MessageRouter | null = null
|
|
77
|
+
|
|
78
|
+
export function getMessageRouter(): MessageRouter {
|
|
79
|
+
if (!_router) {
|
|
80
|
+
_router = new MessageRouter()
|
|
81
|
+
}
|
|
82
|
+
return _router
|
|
83
|
+
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
InferenceHookConfig,
|
|
4
4
|
CredentialMaskingConfig,
|
|
5
5
|
BackgroundAgentConfig,
|
|
6
|
+
CrossSessionConfig,
|
|
6
7
|
} from '../shared/index.ts'
|
|
7
8
|
import { DEFAULT_PROVIDERS } from '../shared/index.ts'
|
|
8
9
|
import { PACKAGE_VERSION } from '../shared/index.ts'
|
|
@@ -51,3 +52,8 @@ export const DEFAULT_BACKGROUND_AGENT_CONFIG: BackgroundAgentConfig = {
|
|
|
51
52
|
auto_worktree: true,
|
|
52
53
|
commit_coauthors: true,
|
|
53
54
|
}
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_CROSS_SESSION_CONFIG: CrossSessionConfig = {
|
|
57
|
+
crossSessionInbound: 'ask',
|
|
58
|
+
dialogExpiry: 300,
|
|
59
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -17,12 +17,14 @@ import type {
|
|
|
17
17
|
InferenceHookConfig,
|
|
18
18
|
CredentialMaskingConfig,
|
|
19
19
|
BackgroundAgentConfig,
|
|
20
|
+
CrossSessionConfig,
|
|
20
21
|
} from '../shared/index.ts'
|
|
21
22
|
import {
|
|
22
23
|
DEFAULT_CONFIG,
|
|
23
24
|
DEFAULT_INFERENCE_HOOK_CONFIG,
|
|
24
25
|
DEFAULT_CREDENTIAL_MASKING_CONFIG,
|
|
25
26
|
DEFAULT_BACKGROUND_AGENT_CONFIG,
|
|
27
|
+
DEFAULT_CROSS_SESSION_CONFIG,
|
|
26
28
|
} from './defaults'
|
|
27
29
|
|
|
28
30
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
@@ -376,3 +378,34 @@ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): Backgrou
|
|
|
376
378
|
|
|
377
379
|
return merged
|
|
378
380
|
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Load cross-session messaging configuration from the same config sources.
|
|
384
|
+
* Merges project-level over user-level. Returns defaults if no section present.
|
|
385
|
+
*/
|
|
386
|
+
export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessionConfig {
|
|
387
|
+
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
388
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
389
|
+
|
|
390
|
+
let merged = { ...DEFAULT_CROSS_SESSION_CONFIG }
|
|
391
|
+
|
|
392
|
+
const paths = [userConfigPath, configPath] // project wins (loaded last)
|
|
393
|
+
for (const path of paths) {
|
|
394
|
+
try {
|
|
395
|
+
if (!existsSync(path)) continue
|
|
396
|
+
const raw = readFileSync(path, 'utf-8')
|
|
397
|
+
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
398
|
+
const section = parsed.cross_session as Partial<CrossSessionConfig> | undefined
|
|
399
|
+
if (section) {
|
|
400
|
+
merged = {
|
|
401
|
+
crossSessionInbound: section.crossSessionInbound ?? merged.crossSessionInbound,
|
|
402
|
+
dialogExpiry: section.dialogExpiry ?? merged.dialogExpiry,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
// Silently skip malformed configs
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return merged
|
|
411
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CREDENTIAL_SENTINEL } from './types'
|
|
2
|
+
import type { CredentialMaskingConfig } from '../../shared/index.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Filter sensitive environment variables before spawning a subprocess.
|
|
6
|
+
* Returns a sanitized copy of process.env with matching vars removed.
|
|
7
|
+
*/
|
|
8
|
+
export function filterEnv(
|
|
9
|
+
env: Record<string, string | undefined>,
|
|
10
|
+
config: CredentialMaskingConfig,
|
|
11
|
+
): Record<string, string | undefined> {
|
|
12
|
+
if (!config.enabled || !config.env_filter.enabled) return { ...env }
|
|
13
|
+
|
|
14
|
+
const filtered: Record<string, string | undefined> = {}
|
|
15
|
+
for (const [key, value] of Object.entries(env)) {
|
|
16
|
+
let blocked = false
|
|
17
|
+
for (const pattern of config.env_filter.patterns) {
|
|
18
|
+
try {
|
|
19
|
+
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
|
20
|
+
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
21
|
+
const regex = new RegExp(clean, 'i')
|
|
22
|
+
if (regex.test(key)) {
|
|
23
|
+
blocked = true
|
|
24
|
+
break
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// Invalid regex — skip
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (blocked) {
|
|
31
|
+
// Replace with sentinel so tools can detect they're filtered
|
|
32
|
+
filtered[key] = CREDENTIAL_SENTINEL
|
|
33
|
+
} else {
|
|
34
|
+
filtered[key] = value
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return filtered
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { CREDENTIAL_SENTINEL } from './types'
|
|
2
|
+
export type { MaskingStrategy } from './types'
|
|
3
|
+
|
|
4
|
+
export { MaskingPipeline, getPipeline, maskContent } from './pipeline'
|
|
5
|
+
export { matchCredentialFile, matchPath, globToRegex } from './matcher'
|
|
6
|
+
export { filterEnv } from './env-filter'
|
|
7
|
+
export { maskOutput } from './output-scrub'
|
|
8
|
+
|
|
9
|
+
// Strategies
|
|
10
|
+
export { FullMaskingStrategy } from './strategies/full'
|
|
11
|
+
export { ExtractMaskingStrategy } from './strategies/extract'
|
|
12
|
+
export { JwtMaskingStrategy } from './strategies/jwt'
|
|
13
|
+
export { AwsMaskingStrategy } from './strategies/aws'
|
|
14
|
+
|
|
15
|
+
import { getPipeline } from './pipeline'
|
|
16
|
+
import { FullMaskingStrategy } from './strategies/full'
|
|
17
|
+
import { ExtractMaskingStrategy } from './strategies/extract'
|
|
18
|
+
import { JwtMaskingStrategy } from './strategies/jwt'
|
|
19
|
+
import { AwsMaskingStrategy } from './strategies/aws'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the masking pipeline with all built-in strategies.
|
|
23
|
+
* Call once at startup. Safe to call multiple times (idempotent).
|
|
24
|
+
*/
|
|
25
|
+
let _initialized = false
|
|
26
|
+
|
|
27
|
+
export function initializePipeline(): void {
|
|
28
|
+
if (_initialized) return
|
|
29
|
+
const pipeline = getPipeline()
|
|
30
|
+
pipeline.register(new FullMaskingStrategy())
|
|
31
|
+
pipeline.register(new ExtractMaskingStrategy())
|
|
32
|
+
pipeline.register(new JwtMaskingStrategy())
|
|
33
|
+
pipeline.register(new AwsMaskingStrategy())
|
|
34
|
+
_initialized = true
|
|
35
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import type { CredentialMaskingConfig, CredentialFileRule } from '../../shared/index.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if a file path matches any credential file rule.
|
|
6
|
+
* Returns the matching rule, or null if no match.
|
|
7
|
+
*/
|
|
8
|
+
export function matchCredentialFile(
|
|
9
|
+
filePath: string,
|
|
10
|
+
config: CredentialMaskingConfig,
|
|
11
|
+
): CredentialFileRule | null {
|
|
12
|
+
if (!config.enabled || config.files.length === 0) return null
|
|
13
|
+
|
|
14
|
+
const expanded = expandHome(filePath)
|
|
15
|
+
for (const rule of config.files) {
|
|
16
|
+
if (matchPath(expanded, rule.path)) {
|
|
17
|
+
return rule
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Internal helpers ──
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Expand ~ to the user's home directory.
|
|
27
|
+
*/
|
|
28
|
+
export function expandHome(p: string): string {
|
|
29
|
+
if (p.startsWith('~')) {
|
|
30
|
+
return homedir() + p.slice(1)
|
|
31
|
+
}
|
|
32
|
+
return p
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Match a file path against a glob-like pattern.
|
|
37
|
+
* Supports:
|
|
38
|
+
* - ** globstar (matches any number of path segments)
|
|
39
|
+
* - * wildcard within a single path segment
|
|
40
|
+
* - ? single char
|
|
41
|
+
* - ~ home directory expansion
|
|
42
|
+
* - basename-only matching (e.g. ".env*" matches any .env file)
|
|
43
|
+
*/
|
|
44
|
+
export function matchPath(filePath: string, pattern: string): boolean {
|
|
45
|
+
const expandedPattern = expandHome(pattern)
|
|
46
|
+
const normalizedFile = filePath.replace(/\\/g, '/')
|
|
47
|
+
const normalizedPattern = expandedPattern.replace(/\\/g, '/')
|
|
48
|
+
|
|
49
|
+
if (normalizedFile === normalizedPattern) return true
|
|
50
|
+
|
|
51
|
+
// File ends with pattern (for relative basename matching like ".env*")
|
|
52
|
+
if (normalizedFile.endsWith('/' + normalizedPattern)) return true
|
|
53
|
+
|
|
54
|
+
const regex = globToRegex(normalizedPattern)
|
|
55
|
+
if (regex.test(normalizedFile)) return true
|
|
56
|
+
|
|
57
|
+
// Basename-only matching: patterns without path separators match
|
|
58
|
+
// against the last component of the file path (e.g. ".env*" matches any .env file)
|
|
59
|
+
if (!normalizedPattern.includes('/')) {
|
|
60
|
+
const basename = normalizedFile.split('/').pop() || normalizedFile
|
|
61
|
+
if (regex.test(basename)) return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
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.
|
|
73
|
+
*/
|
|
74
|
+
export function globToRegex(pattern: string): RegExp {
|
|
75
|
+
let regexStr = ''
|
|
76
|
+
let i = 0
|
|
77
|
+
while (i < pattern.length) {
|
|
78
|
+
const ch = pattern[i]!
|
|
79
|
+
if (ch === '*' && pattern[i + 1] === '*') {
|
|
80
|
+
// ** — match zero or more path segments
|
|
81
|
+
regexStr += '.*'
|
|
82
|
+
i += 2
|
|
83
|
+
// Skip trailing slash after **
|
|
84
|
+
if (pattern[i] === '/') i++
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (ch === '*') {
|
|
88
|
+
regexStr += '[^/]*'
|
|
89
|
+
i++
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (ch === '?') {
|
|
93
|
+
regexStr += '.'
|
|
94
|
+
i++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
// Escape regex special chars
|
|
98
|
+
if ('\\^$.|+()[]{}'.includes(ch)) {
|
|
99
|
+
regexStr += '\\' + ch
|
|
100
|
+
} else {
|
|
101
|
+
regexStr += ch
|
|
102
|
+
}
|
|
103
|
+
i++
|
|
104
|
+
}
|
|
105
|
+
return new RegExp('^' + regexStr + '$')
|
|
106
|
+
}
|