@miphamai/cli 0.11.0 → 0.13.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/package.json +1 -1
- package/src/agent/background-registry.ts +11 -0
- package/src/agent-view/agent-view-manager.ts +10 -0
- package/src/artifacts/server.ts +39 -0
- package/src/config/defaults.ts +35 -1
- package/src/config/loader.ts +122 -2
- package/src/core/credential-masker.ts +203 -0
- package/src/core/engine.ts +56 -1
- package/src/core/hooks.ts +23 -0
- package/src/core/inference-hook.ts +224 -0
- package/src/core/metrics.ts +364 -0
- package/src/core/permission.ts +62 -8
- package/src/index.tsx +16 -1
- package/src/plugin/plugin-manager.ts +39 -2
- package/src/shared/types.ts +100 -0
- package/src/tools/exec/bash.ts +29 -3
- package/src/tools/file/read.ts +30 -1
- package/src/ui/app.tsx +6 -1
- package/src/ui/chat.tsx +50 -17
- package/src/ui/commands.ts +348 -4
- package/dist/mipham +0 -0
package/package.json
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* can be queried via the Task tool (output/stop actions) or the Agent View.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
export type BackgroundTaskKind = 'interactive' | 'forked' | 'attached' | 'unattended'
|
|
10
|
+
|
|
9
11
|
export type BackgroundTaskStatus = 'running' | 'completed' | 'failed'
|
|
10
12
|
|
|
11
13
|
export interface BackgroundTask {
|
|
@@ -13,10 +15,17 @@ export interface BackgroundTask {
|
|
|
13
15
|
description: string
|
|
14
16
|
agentType: string
|
|
15
17
|
status: BackgroundTaskStatus
|
|
18
|
+
kind: BackgroundTaskKind
|
|
16
19
|
startedAt: Date
|
|
17
20
|
completedAt?: Date
|
|
18
21
|
result?: string
|
|
19
22
|
error?: string
|
|
23
|
+
/** Git worktree path (forked tasks) */
|
|
24
|
+
worktree?: string
|
|
25
|
+
/** Git branch name (forked tasks) */
|
|
26
|
+
branch?: string
|
|
27
|
+
/** Created PR URL (if requested) */
|
|
28
|
+
prUrl?: string
|
|
20
29
|
abortController: AbortController
|
|
21
30
|
}
|
|
22
31
|
|
|
@@ -39,6 +48,7 @@ export class BackgroundAgentRegistry {
|
|
|
39
48
|
description: string,
|
|
40
49
|
agentType: string,
|
|
41
50
|
executor: (signal: AbortSignal) => Promise<string>,
|
|
51
|
+
kind: BackgroundTaskKind = 'interactive',
|
|
42
52
|
): string {
|
|
43
53
|
const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
|
|
44
54
|
|
|
@@ -47,6 +57,7 @@ export class BackgroundAgentRegistry {
|
|
|
47
57
|
description,
|
|
48
58
|
agentType,
|
|
49
59
|
status: 'running',
|
|
60
|
+
kind,
|
|
50
61
|
startedAt: new Date(),
|
|
51
62
|
abortController: new AbortController(),
|
|
52
63
|
}
|
|
@@ -13,10 +13,13 @@ export interface SessionMessage {
|
|
|
13
13
|
content: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export type SessionKind = 'interactive' | 'forked' | 'attached' | 'unattended'
|
|
17
|
+
|
|
16
18
|
export interface AgentSession {
|
|
17
19
|
id: string
|
|
18
20
|
title: string
|
|
19
21
|
status: SessionStatus
|
|
22
|
+
kind: SessionKind
|
|
20
23
|
provider: string
|
|
21
24
|
model: string
|
|
22
25
|
task: string
|
|
@@ -25,6 +28,12 @@ export interface AgentSession {
|
|
|
25
28
|
completedAt?: Date
|
|
26
29
|
elapsedMs: number
|
|
27
30
|
messages: SessionMessage[]
|
|
31
|
+
/** Git worktree path (forked sessions) */
|
|
32
|
+
worktree?: string
|
|
33
|
+
/** Git branch name (forked sessions) */
|
|
34
|
+
branch?: string
|
|
35
|
+
/** Created PR URL (if requested) */
|
|
36
|
+
prUrl?: string
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export interface CreateSessionOptions {
|
|
@@ -53,6 +62,7 @@ export class AgentViewManager {
|
|
|
53
62
|
id,
|
|
54
63
|
title,
|
|
55
64
|
status: 'needs-input',
|
|
65
|
+
kind: 'interactive',
|
|
56
66
|
provider: options.provider ?? 'unknown',
|
|
57
67
|
model: options.model ?? 'unknown',
|
|
58
68
|
task,
|
package/src/artifacts/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
|
|
|
5
5
|
import { readManifest } from './manifest'
|
|
6
6
|
import { ArtifactVersioning } from './versioning'
|
|
7
7
|
import type { ArtifactEntry } from '../shared/types'
|
|
8
|
+
import { getMetrics } from '../core/metrics'
|
|
8
9
|
|
|
9
10
|
// ── SSE client tracking ──
|
|
10
11
|
interface SseClient {
|
|
@@ -151,10 +152,48 @@ export class ArtifactServer {
|
|
|
151
152
|
return
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
// Metrics endpoint — Prometheus text format
|
|
156
|
+
if (urlPath === '/metrics') {
|
|
157
|
+
this.serveMetrics(res, 'prometheus')
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Metrics endpoint — JSON format
|
|
162
|
+
if (urlPath === '/metrics/json') {
|
|
163
|
+
this.serveMetrics(res, 'json')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
154
167
|
// Artifact file
|
|
155
168
|
this.serveArtifact(urlPath, res)
|
|
156
169
|
}
|
|
157
170
|
|
|
171
|
+
// ── Metrics ──
|
|
172
|
+
|
|
173
|
+
private serveMetrics(res: any, format: 'prometheus' | 'json'): void {
|
|
174
|
+
try {
|
|
175
|
+
const metrics = getMetrics()
|
|
176
|
+
if (format === 'json') {
|
|
177
|
+
const body = JSON.stringify(metrics.toJSON(), null, 2)
|
|
178
|
+
res.writeHead(200, {
|
|
179
|
+
'Content-Type': 'application/json',
|
|
180
|
+
'Access-Control-Allow-Origin': '*',
|
|
181
|
+
})
|
|
182
|
+
res.end(body)
|
|
183
|
+
} else {
|
|
184
|
+
const body = metrics.toPrometheusText()
|
|
185
|
+
res.writeHead(200, {
|
|
186
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
187
|
+
'Access-Control-Allow-Origin': '*',
|
|
188
|
+
})
|
|
189
|
+
res.end(body)
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
193
|
+
res.end(`Metrics error: ${err instanceof Error ? err.message : 'unknown'}`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
158
197
|
// ── SSE ──
|
|
159
198
|
|
|
160
199
|
private handleSse(res: any): void {
|
package/src/config/defaults.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
MiphamConfig,
|
|
3
|
+
InferenceHookConfig,
|
|
4
|
+
CredentialMaskingConfig,
|
|
5
|
+
BackgroundAgentConfig,
|
|
6
|
+
} from '../shared/index.ts'
|
|
2
7
|
import { DEFAULT_PROVIDERS } from '../shared/index.ts'
|
|
3
8
|
import { PACKAGE_VERSION } from '../shared/index.ts'
|
|
4
9
|
|
|
@@ -9,3 +14,32 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
9
14
|
permission: 'auto',
|
|
10
15
|
providers: DEFAULT_PROVIDERS,
|
|
11
16
|
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
|
|
19
|
+
endpoint: '',
|
|
20
|
+
signing_secret: '',
|
|
21
|
+
timeout: 5000,
|
|
22
|
+
on_failure: 'fail-closed',
|
|
23
|
+
organization_id: '',
|
|
24
|
+
headers: {},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_CREDENTIAL_MASKING_CONFIG: CredentialMaskingConfig = {
|
|
28
|
+
enabled: true,
|
|
29
|
+
files: [],
|
|
30
|
+
output_scrubbing: {
|
|
31
|
+
enabled: true,
|
|
32
|
+
patterns: ['(?i)(api[_-]?key|secret|token|password|credential)\\s*[:=]\\s*\\S+'],
|
|
33
|
+
},
|
|
34
|
+
env_filter: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
patterns: ['(?i)(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)$'],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_BACKGROUND_AGENT_CONFIG: BackgroundAgentConfig = {
|
|
41
|
+
auto_commit: true,
|
|
42
|
+
auto_push: true,
|
|
43
|
+
auto_worktree: true,
|
|
44
|
+
commit_coauthors: true,
|
|
45
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -10,8 +10,20 @@ import {
|
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
11
|
import { homedir } from 'node:os'
|
|
12
12
|
import { parse as parseYaml } from 'yaml'
|
|
13
|
-
import type {
|
|
14
|
-
|
|
13
|
+
import type {
|
|
14
|
+
MiphamConfig,
|
|
15
|
+
ProviderConfig,
|
|
16
|
+
McpServerConfig,
|
|
17
|
+
InferenceHookConfig,
|
|
18
|
+
CredentialMaskingConfig,
|
|
19
|
+
BackgroundAgentConfig,
|
|
20
|
+
} from '../shared/index.ts'
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_CONFIG,
|
|
23
|
+
DEFAULT_INFERENCE_HOOK_CONFIG,
|
|
24
|
+
DEFAULT_CREDENTIAL_MASKING_CONFIG,
|
|
25
|
+
DEFAULT_BACKGROUND_AGENT_CONFIG,
|
|
26
|
+
} from './defaults'
|
|
15
27
|
|
|
16
28
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
17
29
|
const BACKUP_PREFIX = 'config.backup-'
|
|
@@ -256,3 +268,111 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
|
256
268
|
|
|
257
269
|
return config
|
|
258
270
|
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Load inference hooks (DLP) configuration from the same config sources
|
|
274
|
+
* as the main config. Merges project-level over user-level.
|
|
275
|
+
*
|
|
276
|
+
* Returns default (disabled) config if no inference_hooks section is present.
|
|
277
|
+
*/
|
|
278
|
+
export function loadInferenceHookConfig(cwd: string = process.cwd()): InferenceHookConfig {
|
|
279
|
+
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
280
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
281
|
+
|
|
282
|
+
let merged = { ...DEFAULT_INFERENCE_HOOK_CONFIG }
|
|
283
|
+
|
|
284
|
+
const paths = [userConfigPath, configPath] // project wins (loaded last)
|
|
285
|
+
for (const path of paths) {
|
|
286
|
+
try {
|
|
287
|
+
if (!existsSync(path)) continue
|
|
288
|
+
const raw = readFileSync(path, 'utf-8')
|
|
289
|
+
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
290
|
+
const section = parsed.inference_hooks as Partial<InferenceHookConfig> | undefined
|
|
291
|
+
if (section) {
|
|
292
|
+
merged = {
|
|
293
|
+
endpoint: section.endpoint ?? merged.endpoint,
|
|
294
|
+
signing_secret: section.signing_secret ?? merged.signing_secret,
|
|
295
|
+
timeout: section.timeout ?? merged.timeout,
|
|
296
|
+
on_failure: section.on_failure ?? merged.on_failure,
|
|
297
|
+
organization_id: section.organization_id ?? merged.organization_id,
|
|
298
|
+
headers: { ...merged.headers, ...(section.headers || {}) },
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
// Silently skip malformed configs — main loadConfig already warns
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return merged
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Load credential masking configuration from the same config sources.
|
|
311
|
+
* Merges project-level over user-level. Returns defaults if no section present.
|
|
312
|
+
*/
|
|
313
|
+
export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
|
|
314
|
+
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
315
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
316
|
+
|
|
317
|
+
let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
|
|
318
|
+
|
|
319
|
+
const paths = [userConfigPath, configPath] // project wins (loaded last)
|
|
320
|
+
for (const path of paths) {
|
|
321
|
+
try {
|
|
322
|
+
if (!existsSync(path)) continue
|
|
323
|
+
const raw = readFileSync(path, 'utf-8')
|
|
324
|
+
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
325
|
+
const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
|
|
326
|
+
if (section) {
|
|
327
|
+
merged = {
|
|
328
|
+
enabled: section.enabled ?? merged.enabled,
|
|
329
|
+
files: section.files ?? merged.files,
|
|
330
|
+
output_scrubbing: {
|
|
331
|
+
enabled: section.output_scrubbing?.enabled ?? merged.output_scrubbing.enabled,
|
|
332
|
+
patterns: section.output_scrubbing?.patterns ?? merged.output_scrubbing.patterns,
|
|
333
|
+
},
|
|
334
|
+
env_filter: {
|
|
335
|
+
enabled: section.env_filter?.enabled ?? merged.env_filter.enabled,
|
|
336
|
+
patterns: section.env_filter?.patterns ?? merged.env_filter.patterns,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// Silently skip malformed configs
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return merged
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Load background agent configuration from config sources.
|
|
350
|
+
*/
|
|
351
|
+
export function loadBackgroundAgentConfig(cwd: string = process.cwd()): BackgroundAgentConfig {
|
|
352
|
+
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
353
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
354
|
+
|
|
355
|
+
let merged = { ...DEFAULT_BACKGROUND_AGENT_CONFIG }
|
|
356
|
+
|
|
357
|
+
const paths = [userConfigPath, configPath]
|
|
358
|
+
for (const path of paths) {
|
|
359
|
+
try {
|
|
360
|
+
if (!existsSync(path)) continue
|
|
361
|
+
const raw = readFileSync(path, 'utf-8')
|
|
362
|
+
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
363
|
+
const section = parsed.background_agent as Partial<BackgroundAgentConfig> | undefined
|
|
364
|
+
if (section) {
|
|
365
|
+
merged = {
|
|
366
|
+
auto_commit: section.auto_commit ?? merged.auto_commit,
|
|
367
|
+
auto_push: section.auto_push ?? merged.auto_push,
|
|
368
|
+
auto_worktree: section.auto_worktree ?? merged.auto_worktree,
|
|
369
|
+
commit_coauthors: section.commit_coauthors ?? merged.commit_coauthors,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
// Silently skip malformed configs
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return merged
|
|
378
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { relative, isAbsolute } from 'node:path'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import type { CredentialMaskingConfig, CredentialFileRule } from '../shared/index.ts'
|
|
4
|
+
|
|
5
|
+
/** Sentinel value used to replace masked credentials. */
|
|
6
|
+
export const CREDENTIAL_SENTINEL = '__MIPHAM_CREDENTIAL_MASKED__'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if a file path matches any credential file rule.
|
|
10
|
+
* Returns the matching rule, or null if no match.
|
|
11
|
+
*/
|
|
12
|
+
export function matchCredentialFile(
|
|
13
|
+
filePath: string,
|
|
14
|
+
config: CredentialMaskingConfig,
|
|
15
|
+
): CredentialFileRule | null {
|
|
16
|
+
if (!config.enabled || config.files.length === 0) return null
|
|
17
|
+
|
|
18
|
+
const expanded = expandHome(filePath)
|
|
19
|
+
for (const rule of config.files) {
|
|
20
|
+
if (matchPath(expanded, rule.path)) {
|
|
21
|
+
return rule
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Apply credential masking to file content.
|
|
29
|
+
* - 'full' mode: return sentinel value only
|
|
30
|
+
* - 'extract' mode: replace regex-matched tokens with sentinel
|
|
31
|
+
*/
|
|
32
|
+
export function maskContent(content: string, rule: CredentialFileRule): string {
|
|
33
|
+
if (rule.mode === 'full') {
|
|
34
|
+
return CREDENTIAL_SENTINEL
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// extract mode
|
|
38
|
+
let masked = content
|
|
39
|
+
for (const extractRule of rule.extract) {
|
|
40
|
+
const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
|
|
41
|
+
try {
|
|
42
|
+
const regex = new RegExp(extractRule.pattern, 'gm')
|
|
43
|
+
masked = masked.replace(regex, replacement)
|
|
44
|
+
} catch {
|
|
45
|
+
// Invalid regex — skip this rule silently
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return masked
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Scrub credential patterns from stdout/stderr output.
|
|
53
|
+
* Uses the configured output_scrubbing patterns to detect and replace
|
|
54
|
+
* credentials that may have leaked into command output.
|
|
55
|
+
*/
|
|
56
|
+
export function maskOutput(output: string, config: CredentialMaskingConfig): string {
|
|
57
|
+
if (!config.enabled || !config.output_scrubbing.enabled) return output
|
|
58
|
+
|
|
59
|
+
let masked = output
|
|
60
|
+
for (const pattern of config.output_scrubbing.patterns) {
|
|
61
|
+
try {
|
|
62
|
+
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
|
63
|
+
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
64
|
+
const regex = new RegExp(clean, 'gim')
|
|
65
|
+
masked = masked.replace(regex, (match) => {
|
|
66
|
+
// Replace everything after the separator (= or :)
|
|
67
|
+
return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
|
|
68
|
+
})
|
|
69
|
+
} catch {
|
|
70
|
+
// Invalid regex — skip
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return masked
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Filter sensitive environment variables before spawning a subprocess.
|
|
78
|
+
* Returns a sanitized copy of process.env with matching vars removed.
|
|
79
|
+
*/
|
|
80
|
+
export function filterEnv(
|
|
81
|
+
env: Record<string, string | undefined>,
|
|
82
|
+
config: CredentialMaskingConfig,
|
|
83
|
+
): Record<string, string | undefined> {
|
|
84
|
+
if (!config.enabled || !config.env_filter.enabled) return { ...env }
|
|
85
|
+
|
|
86
|
+
const filtered: Record<string, string | undefined> = {}
|
|
87
|
+
for (const [key, value] of Object.entries(env)) {
|
|
88
|
+
let blocked = false
|
|
89
|
+
for (const pattern of config.env_filter.patterns) {
|
|
90
|
+
try {
|
|
91
|
+
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
|
92
|
+
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
93
|
+
const regex = new RegExp(clean, 'i')
|
|
94
|
+
if (regex.test(key)) {
|
|
95
|
+
blocked = true
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Invalid regex — skip
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (blocked) {
|
|
103
|
+
// Replace with sentinel so tools can detect they're filtered
|
|
104
|
+
filtered[key] = CREDENTIAL_SENTINEL
|
|
105
|
+
} else {
|
|
106
|
+
filtered[key] = value
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return filtered
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the file display name for a sentinel value (for model visibility).
|
|
115
|
+
* The model sees the sentinel, doesn't know the original file path.
|
|
116
|
+
*/
|
|
117
|
+
export function getSentinelDisplay(hint?: string): string {
|
|
118
|
+
if (hint) {
|
|
119
|
+
return `[Credential file masked: ${hint}]`
|
|
120
|
+
}
|
|
121
|
+
return CREDENTIAL_SENTINEL
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Internal helpers ──
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Expand ~ to the user's home directory.
|
|
128
|
+
*/
|
|
129
|
+
function expandHome(p: string): string {
|
|
130
|
+
if (p.startsWith('~')) {
|
|
131
|
+
return homedir() + p.slice(1)
|
|
132
|
+
}
|
|
133
|
+
return p
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Match a file path against a glob-like pattern.
|
|
138
|
+
* Supports:
|
|
139
|
+
* - ** globstar (matches any number of path segments)
|
|
140
|
+
* - * wildcard within a single path segment
|
|
141
|
+
* - ~ home directory expansion
|
|
142
|
+
* - basename-only matching (e.g. ".env*" matches any .env file)
|
|
143
|
+
*/
|
|
144
|
+
function matchPath(filePath: string, pattern: string): boolean {
|
|
145
|
+
const expandedPattern = expandHome(pattern)
|
|
146
|
+
const normalizedFile = filePath.replace(/\\/g, '/')
|
|
147
|
+
const normalizedPattern = expandedPattern.replace(/\\/g, '/')
|
|
148
|
+
|
|
149
|
+
// Exact match
|
|
150
|
+
if (normalizedFile === normalizedPattern) {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// File ends with pattern (for relative basename matching like ".env*")
|
|
155
|
+
if (normalizedFile.endsWith('/' + normalizedPattern)) {
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Convert glob pattern to regex
|
|
160
|
+
const regex = globToRegex(normalizedPattern)
|
|
161
|
+
return regex.test(normalizedFile)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Convert a glob pattern to a RegExp.
|
|
166
|
+
* - ** → .* (matches any path segments)
|
|
167
|
+
* - * → [^/]* (matches within a single segment)
|
|
168
|
+
* - ? → . (single char)
|
|
169
|
+
* - All other special regex chars are escaped.
|
|
170
|
+
*/
|
|
171
|
+
function globToRegex(pattern: string): RegExp {
|
|
172
|
+
let regexStr = ''
|
|
173
|
+
let i = 0
|
|
174
|
+
while (i < pattern.length) {
|
|
175
|
+
const ch = pattern[i]!
|
|
176
|
+
if (ch === '*' && pattern[i + 1] === '*') {
|
|
177
|
+
// ** — match zero or more path segments
|
|
178
|
+
regexStr += '.*'
|
|
179
|
+
i += 2
|
|
180
|
+
// Skip trailing slash after **
|
|
181
|
+
if (pattern[i] === '/') i++
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (ch === '*') {
|
|
185
|
+
regexStr += '[^/]*'
|
|
186
|
+
i++
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
if (ch === '?') {
|
|
190
|
+
regexStr += '.'
|
|
191
|
+
i++
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
// Escape regex special chars
|
|
195
|
+
if ('\\^$.|+()[]{}'.includes(ch)) {
|
|
196
|
+
regexStr += '\\' + ch
|
|
197
|
+
} else {
|
|
198
|
+
regexStr += ch
|
|
199
|
+
}
|
|
200
|
+
i++
|
|
201
|
+
}
|
|
202
|
+
return new RegExp('^' + regexStr + '$')
|
|
203
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
StreamChunk,
|
|
3
|
+
ToolDefinition,
|
|
4
|
+
ToolResult,
|
|
5
|
+
InferenceHookConfig,
|
|
6
|
+
} from '../shared/index.ts'
|
|
2
7
|
import { ProviderRegistry } from '../providers/registry'
|
|
3
8
|
import { ContextManager } from './context'
|
|
4
9
|
import { PermissionSystem } from './permission'
|
|
@@ -11,6 +16,7 @@ import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
|
11
16
|
import type { SkillsLoader } from '../skills/loader'
|
|
12
17
|
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
13
18
|
import { RulesLoader } from './rules-loader'
|
|
19
|
+
import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
|
|
14
20
|
|
|
15
21
|
export class QueryEngine {
|
|
16
22
|
private hookEngine?: HookEngine
|
|
@@ -76,6 +82,8 @@ export class QueryEngine {
|
|
|
76
82
|
private rulesLoader?: RulesLoader
|
|
77
83
|
/** Files touched in the current turn (for rules matching). */
|
|
78
84
|
private touchedFiles: Set<string> = new Set()
|
|
85
|
+
/** Inference hook (DLP) configuration. */
|
|
86
|
+
private inferenceHookConfig?: InferenceHookConfig
|
|
79
87
|
|
|
80
88
|
/** Register the rules loader. */
|
|
81
89
|
setRulesLoader(loader: RulesLoader): void {
|
|
@@ -83,6 +91,11 @@ export class QueryEngine {
|
|
|
83
91
|
this.rulesLoader.load()
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
/** Register inference hook (DLP) configuration. */
|
|
95
|
+
setInferenceHookConfig(config: InferenceHookConfig): void {
|
|
96
|
+
this.inferenceHookConfig = config
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
/** Pending task notifications from background agents (cleared after draining). */
|
|
87
100
|
private pendingTaskNotifications: Array<StreamChunk> = []
|
|
88
101
|
|
|
@@ -256,6 +269,27 @@ export class QueryEngine {
|
|
|
256
269
|
const messages = this.context.getMessages()
|
|
257
270
|
const toolDefs = this.getToolDefinitions()
|
|
258
271
|
|
|
272
|
+
// ── PreInference DLP checkpoint ──
|
|
273
|
+
if (isInferenceHookEnabled(this.inferenceHookConfig)) {
|
|
274
|
+
const provider = this.registry.getActive().config.id
|
|
275
|
+
const model = this.registry.getActiveModel()
|
|
276
|
+
const request = buildRequest(
|
|
277
|
+
messages,
|
|
278
|
+
'session-1',
|
|
279
|
+
provider,
|
|
280
|
+
model,
|
|
281
|
+
this.inferenceHookConfig!.organization_id,
|
|
282
|
+
)
|
|
283
|
+
const verdict = await sendInferenceCheck(this.inferenceHookConfig!, request)
|
|
284
|
+
if (!verdict.allowed) {
|
|
285
|
+
yield {
|
|
286
|
+
type: 'error',
|
|
287
|
+
error: verdict.reason || 'Request blocked by DLP policy.',
|
|
288
|
+
}
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
259
293
|
let assistantContent = ''
|
|
260
294
|
let reasoningContent = ''
|
|
261
295
|
let thinkingContent = ''
|
|
@@ -481,6 +515,27 @@ export class QueryEngine {
|
|
|
481
515
|
const systemPrompt = this.context.getSystemPrompt()
|
|
482
516
|
const messages = this.context.getMessages()
|
|
483
517
|
|
|
518
|
+
// ── PreInference DLP checkpoint (every tool-calling turn) ──
|
|
519
|
+
if (isInferenceHookEnabled(this.inferenceHookConfig)) {
|
|
520
|
+
const provider = this.registry.getActive().config.id
|
|
521
|
+
const model = this.registry.getActiveModel()
|
|
522
|
+
const request = buildRequest(
|
|
523
|
+
messages,
|
|
524
|
+
'session-1',
|
|
525
|
+
provider,
|
|
526
|
+
model,
|
|
527
|
+
this.inferenceHookConfig!.organization_id,
|
|
528
|
+
)
|
|
529
|
+
const verdict = await sendInferenceCheck(this.inferenceHookConfig!, request)
|
|
530
|
+
if (!verdict.allowed) {
|
|
531
|
+
yield {
|
|
532
|
+
type: 'error',
|
|
533
|
+
error: verdict.reason || 'Request blocked by DLP policy.',
|
|
534
|
+
}
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
484
539
|
let assistantContent = ''
|
|
485
540
|
let reasoningContent = ''
|
|
486
541
|
let thinkingContent = ''
|
package/src/core/hooks.ts
CHANGED
|
@@ -155,6 +155,29 @@ export class HookEngine {
|
|
|
155
155
|
return this.runHooks('PostToolUseFailure', toolName, ctx)
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** PreInference: fires before every model API call for DLP inspection. */
|
|
159
|
+
async executePreInference(
|
|
160
|
+
messages: Array<{ role: string; content: string }>,
|
|
161
|
+
toolCalls: Array<{
|
|
162
|
+
name: string
|
|
163
|
+
input: Record<string, unknown>
|
|
164
|
+
resultPreview: string
|
|
165
|
+
}>,
|
|
166
|
+
sessionId: string,
|
|
167
|
+
provider: string,
|
|
168
|
+
model: string,
|
|
169
|
+
): Promise<HookResult> {
|
|
170
|
+
const ctx: HookContext = {
|
|
171
|
+
event: 'PreInference',
|
|
172
|
+
sessionId,
|
|
173
|
+
messages,
|
|
174
|
+
toolCalls,
|
|
175
|
+
provider,
|
|
176
|
+
model,
|
|
177
|
+
}
|
|
178
|
+
return this.runHooks('PreInference', undefined, ctx)
|
|
179
|
+
}
|
|
180
|
+
|
|
158
181
|
// ── Core execution ──
|
|
159
182
|
|
|
160
183
|
private async runHooks(
|