@miphamai/cli 0.10.0 → 0.12.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/agent-context.ts +60 -1
- package/src/agent/agent-registry.ts +1 -0
- package/src/agent/types.ts +2 -0
- package/src/artifacts/server.ts +39 -0
- package/src/config/defaults.ts +10 -1
- package/src/config/loader.ts +44 -2
- package/src/core/engine.ts +94 -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/output-styles.ts +95 -0
- package/src/core/rules-loader.ts +175 -0
- package/src/index.tsx +9 -1
- package/src/mcp/client.ts +9 -0
- package/src/shared/types.ts +57 -0
- package/src/tools/index.ts +2 -0
- package/src/tools/system/tool-search.ts +143 -0
- package/src/ui/commands.ts +161 -43
- package/dist/mipham +0 -0
package/package.json
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
// apps/cli/src/agent/agent-context.ts
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
2
5
|
import { ContextManager } from '../core/context'
|
|
3
6
|
import type { ToolDefinition } from '../shared/index.ts'
|
|
4
7
|
import type { AgentDefinition } from './types'
|
|
@@ -8,6 +11,50 @@ export interface AgentContextResult {
|
|
|
8
11
|
allowedTools: ToolDefinition[]
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Load agent memory files from the appropriate scope directory.
|
|
16
|
+
* Returns combined content for injection into the system prompt.
|
|
17
|
+
*/
|
|
18
|
+
function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local'): string {
|
|
19
|
+
let memoryDir: string
|
|
20
|
+
const home = homedir()
|
|
21
|
+
|
|
22
|
+
switch (scope) {
|
|
23
|
+
case 'user':
|
|
24
|
+
memoryDir = join(home, '.mipham', 'agent-memory', agentName)
|
|
25
|
+
break
|
|
26
|
+
case 'project':
|
|
27
|
+
memoryDir = join(process.cwd(), '.mipham', 'agent-memory', agentName)
|
|
28
|
+
break
|
|
29
|
+
case 'local':
|
|
30
|
+
memoryDir = join(process.cwd(), '.mipham', 'agent-memory-local', agentName)
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!existsSync(memoryDir)) return ''
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
|
|
38
|
+
if (files.length === 0) return ''
|
|
39
|
+
|
|
40
|
+
const contents: string[] = []
|
|
41
|
+
for (const file of files.slice(0, 10)) {
|
|
42
|
+
// max 10 files
|
|
43
|
+
try {
|
|
44
|
+
const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
|
|
45
|
+
if (content) contents.push(content)
|
|
46
|
+
} catch {
|
|
47
|
+
// skip unreadable
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (contents.length === 0) return ''
|
|
52
|
+
return [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
|
|
53
|
+
} catch {
|
|
54
|
+
return ''
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
11
58
|
/**
|
|
12
59
|
* Create an isolated context and tool set for a sub-agent.
|
|
13
60
|
*
|
|
@@ -15,6 +62,9 @@ export interface AgentContextResult {
|
|
|
15
62
|
* 1. If `tools` is set, only those tools are allowed.
|
|
16
63
|
* 2. If `disallowedTools` is set, those are removed from the full set.
|
|
17
64
|
* 3. If neither is set, all tools are available.
|
|
65
|
+
*
|
|
66
|
+
* Agent memory: if agentDef.memory is set, loads memory files from the
|
|
67
|
+
* appropriate scope and injects them into the system prompt.
|
|
18
68
|
*/
|
|
19
69
|
export function createAgentContext(
|
|
20
70
|
agentDef: AgentDefinition,
|
|
@@ -27,7 +77,16 @@ export function createAgentContext(
|
|
|
27
77
|
compactionThreshold: 0.85,
|
|
28
78
|
})
|
|
29
79
|
|
|
30
|
-
|
|
80
|
+
// Build system prompt with optional agent memory
|
|
81
|
+
let systemPrompt = agentDef.systemPrompt
|
|
82
|
+
if (agentDef.memory) {
|
|
83
|
+
const memory = loadAgentMemory(agentDef.name, agentDef.memory)
|
|
84
|
+
if (memory) {
|
|
85
|
+
systemPrompt = `${systemPrompt}\n\n---\n\n${memory}`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
context.setSystemPrompt(systemPrompt)
|
|
31
90
|
|
|
32
91
|
// Scope tools
|
|
33
92
|
let allowedTools = Array.from(toolRegistry.values())
|
package/src/agent/types.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface AgentFrontmatter {
|
|
|
12
12
|
maxTurns?: number
|
|
13
13
|
skills?: string
|
|
14
14
|
background?: boolean
|
|
15
|
+
memory?: 'user' | 'project' | 'local' // agent memory scope
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface AgentDefinition {
|
|
@@ -27,6 +28,7 @@ export interface AgentDefinition {
|
|
|
27
28
|
background: boolean
|
|
28
29
|
source: 'builtin' | 'project' | 'user'
|
|
29
30
|
filePath?: string
|
|
31
|
+
memory?: 'user' | 'project' | 'local' // agent memory scope
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface SubAgentOptions {
|
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,4 @@
|
|
|
1
|
-
import type { MiphamConfig } from '../shared/index.ts'
|
|
1
|
+
import type { MiphamConfig, InferenceHookConfig } from '../shared/index.ts'
|
|
2
2
|
import { DEFAULT_PROVIDERS } from '../shared/index.ts'
|
|
3
3
|
import { PACKAGE_VERSION } from '../shared/index.ts'
|
|
4
4
|
|
|
@@ -9,3 +9,12 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
9
9
|
permission: 'auto',
|
|
10
10
|
providers: DEFAULT_PROVIDERS,
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
|
|
14
|
+
endpoint: '',
|
|
15
|
+
signing_secret: '',
|
|
16
|
+
timeout: 5000,
|
|
17
|
+
on_failure: 'fail-closed',
|
|
18
|
+
organization_id: '',
|
|
19
|
+
headers: {},
|
|
20
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -10,8 +10,13 @@ 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
|
+
} from '../shared/index.ts'
|
|
19
|
+
import { DEFAULT_CONFIG, DEFAULT_INFERENCE_HOOK_CONFIG } from './defaults'
|
|
15
20
|
|
|
16
21
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
17
22
|
const BACKUP_PREFIX = 'config.backup-'
|
|
@@ -256,3 +261,40 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
|
256
261
|
|
|
257
262
|
return config
|
|
258
263
|
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Load inference hooks (DLP) configuration from the same config sources
|
|
267
|
+
* as the main config. Merges project-level over user-level.
|
|
268
|
+
*
|
|
269
|
+
* Returns default (disabled) config if no inference_hooks section is present.
|
|
270
|
+
*/
|
|
271
|
+
export function loadInferenceHookConfig(cwd: string = process.cwd()): InferenceHookConfig {
|
|
272
|
+
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
273
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
274
|
+
|
|
275
|
+
let merged = { ...DEFAULT_INFERENCE_HOOK_CONFIG }
|
|
276
|
+
|
|
277
|
+
const paths = [userConfigPath, configPath] // project wins (loaded last)
|
|
278
|
+
for (const path of paths) {
|
|
279
|
+
try {
|
|
280
|
+
if (!existsSync(path)) continue
|
|
281
|
+
const raw = readFileSync(path, 'utf-8')
|
|
282
|
+
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
283
|
+
const section = parsed.inference_hooks as Partial<InferenceHookConfig> | undefined
|
|
284
|
+
if (section) {
|
|
285
|
+
merged = {
|
|
286
|
+
endpoint: section.endpoint ?? merged.endpoint,
|
|
287
|
+
signing_secret: section.signing_secret ?? merged.signing_secret,
|
|
288
|
+
timeout: section.timeout ?? merged.timeout,
|
|
289
|
+
on_failure: section.on_failure ?? merged.on_failure,
|
|
290
|
+
organization_id: section.organization_id ?? merged.organization_id,
|
|
291
|
+
headers: { ...merged.headers, ...(section.headers || {}) },
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// Silently skip malformed configs — main loadConfig already warns
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return merged
|
|
300
|
+
}
|
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'
|
|
@@ -10,6 +15,8 @@ import { getMemoryManager } from './memory/memory-loader'
|
|
|
10
15
|
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'
|
|
18
|
+
import { RulesLoader } from './rules-loader'
|
|
19
|
+
import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
|
|
13
20
|
|
|
14
21
|
export class QueryEngine {
|
|
15
22
|
private hookEngine?: HookEngine
|
|
@@ -71,9 +78,47 @@ export class QueryEngine {
|
|
|
71
78
|
this.skillsLoader = loader
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/** Rules loader for path-scoped rules injection. */
|
|
82
|
+
private rulesLoader?: RulesLoader
|
|
83
|
+
/** Files touched in the current turn (for rules matching). */
|
|
84
|
+
private touchedFiles: Set<string> = new Set()
|
|
85
|
+
/** Inference hook (DLP) configuration. */
|
|
86
|
+
private inferenceHookConfig?: InferenceHookConfig
|
|
87
|
+
|
|
88
|
+
/** Register the rules loader. */
|
|
89
|
+
setRulesLoader(loader: RulesLoader): void {
|
|
90
|
+
this.rulesLoader = loader
|
|
91
|
+
this.rulesLoader.load()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Register inference hook (DLP) configuration. */
|
|
95
|
+
setInferenceHookConfig(config: InferenceHookConfig): void {
|
|
96
|
+
this.inferenceHookConfig = config
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
/** Pending task notifications from background agents (cleared after draining). */
|
|
75
100
|
private pendingTaskNotifications: Array<StreamChunk> = []
|
|
76
101
|
|
|
102
|
+
/** Track files touched by tools for rules matching. */
|
|
103
|
+
private trackTouchedFile(toolName: string, params: Record<string, unknown>): void {
|
|
104
|
+
const fileTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep']
|
|
105
|
+
if (!fileTools.includes(toolName)) return
|
|
106
|
+
const filePath = (params.file_path || params.path || params.file) as string | undefined
|
|
107
|
+
if (filePath && typeof filePath === 'string') {
|
|
108
|
+
this.touchedFiles.add(filePath)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Inject matching rules as context after tool execution. */
|
|
113
|
+
private injectRules(): void {
|
|
114
|
+
if (!this.rulesLoader || this.touchedFiles.size === 0) return
|
|
115
|
+
const files = Array.from(this.touchedFiles)
|
|
116
|
+
const block = this.rulesLoader.buildContextBlock(files)
|
|
117
|
+
if (!block) return
|
|
118
|
+
this.context.addMessage({ role: 'user', content: block })
|
|
119
|
+
this.touchedFiles.clear()
|
|
120
|
+
}
|
|
121
|
+
|
|
77
122
|
/**
|
|
78
123
|
* Drain pending background task notifications.
|
|
79
124
|
* Call this after tool execution to surface completed/failed background agent results.
|
|
@@ -224,6 +269,27 @@ export class QueryEngine {
|
|
|
224
269
|
const messages = this.context.getMessages()
|
|
225
270
|
const toolDefs = this.getToolDefinitions()
|
|
226
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
|
+
|
|
227
293
|
let assistantContent = ''
|
|
228
294
|
let reasoningContent = ''
|
|
229
295
|
let thinkingContent = ''
|
|
@@ -351,6 +417,9 @@ export class QueryEngine {
|
|
|
351
417
|
})
|
|
352
418
|
}
|
|
353
419
|
|
|
420
|
+
// Inject path-scoped rules for touched files
|
|
421
|
+
this.injectRules()
|
|
422
|
+
|
|
354
423
|
// Drain task notifications after tool execution
|
|
355
424
|
for (const chunk of this.drainTaskNotifications()) {
|
|
356
425
|
yield chunk
|
|
@@ -446,6 +515,27 @@ export class QueryEngine {
|
|
|
446
515
|
const systemPrompt = this.context.getSystemPrompt()
|
|
447
516
|
const messages = this.context.getMessages()
|
|
448
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
|
+
|
|
449
539
|
let assistantContent = ''
|
|
450
540
|
let reasoningContent = ''
|
|
451
541
|
let thinkingContent = ''
|
|
@@ -633,6 +723,9 @@ export class QueryEngine {
|
|
|
633
723
|
backgroundAgentRegistry: getBackgroundAgentRegistry(),
|
|
634
724
|
})
|
|
635
725
|
|
|
726
|
+
// Track touched files for rules matching
|
|
727
|
+
this.trackTouchedFile(name, effectiveParams)
|
|
728
|
+
|
|
636
729
|
// Run PostToolUse hooks
|
|
637
730
|
if (this.hookEngine) {
|
|
638
731
|
await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
|
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(
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from 'node:crypto'
|
|
2
|
+
import type {
|
|
3
|
+
InferenceHookConfig,
|
|
4
|
+
InferenceCheckRequest,
|
|
5
|
+
InferenceCheckResponse,
|
|
6
|
+
Message,
|
|
7
|
+
} from '../shared/index.ts'
|
|
8
|
+
|
|
9
|
+
// ── Result type ──
|
|
10
|
+
|
|
11
|
+
export interface InferenceVerdict {
|
|
12
|
+
allowed: boolean
|
|
13
|
+
reason?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── Public API ──
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the inference-check request payload from current conversation state.
|
|
20
|
+
*
|
|
21
|
+
* Follows Claude Inference Hooks protocol:
|
|
22
|
+
* - Sends all messages EXCEPT system role (never expose system prompts)
|
|
23
|
+
* - Includes recent tool calls with result previews (truncated to 2000 chars)
|
|
24
|
+
* - Omits tool definitions and raw file/image content
|
|
25
|
+
*/
|
|
26
|
+
export function buildRequest(
|
|
27
|
+
messages: Message[],
|
|
28
|
+
sessionId: string,
|
|
29
|
+
provider: string,
|
|
30
|
+
model: string,
|
|
31
|
+
organizationId?: string,
|
|
32
|
+
): InferenceCheckRequest {
|
|
33
|
+
// Filter: exclude system messages, flatten content blocks to text
|
|
34
|
+
const serialized: Array<{ role: string; content: string }> = []
|
|
35
|
+
for (const msg of messages) {
|
|
36
|
+
if (msg.role === 'system') continue
|
|
37
|
+
serialized.push({
|
|
38
|
+
role: msg.role,
|
|
39
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Extract tool calls from the message history
|
|
44
|
+
const toolCalls = extractToolCalls(messages)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
type: 'inference_check',
|
|
48
|
+
id: `evt_${randomUUID()}`,
|
|
49
|
+
created_at: new Date().toISOString(),
|
|
50
|
+
data: {
|
|
51
|
+
type: 'pre_inference',
|
|
52
|
+
session_id: sessionId,
|
|
53
|
+
organization_id: organizationId || undefined,
|
|
54
|
+
provider,
|
|
55
|
+
model,
|
|
56
|
+
messages: serialized,
|
|
57
|
+
tool_calls: toolCalls,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Send the inference-check request to the DLP server and return the verdict.
|
|
64
|
+
*
|
|
65
|
+
* On network error or timeout, applies the configured `on_failure` strategy:
|
|
66
|
+
* - 'fail-closed': block the request (security-first)
|
|
67
|
+
* - 'fail-open': allow the request (availability-first)
|
|
68
|
+
*/
|
|
69
|
+
export async function sendInferenceCheck(
|
|
70
|
+
config: InferenceHookConfig,
|
|
71
|
+
request: InferenceCheckRequest,
|
|
72
|
+
): Promise<InferenceVerdict> {
|
|
73
|
+
const body = JSON.stringify(request)
|
|
74
|
+
|
|
75
|
+
// Build signature header
|
|
76
|
+
const signature = signPayload(config.signing_secret, body)
|
|
77
|
+
|
|
78
|
+
const headers: Record<string, string> = {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
'User-Agent': `MiphamCode/${getVersion()}`,
|
|
81
|
+
...(config.headers || {}),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (signature) {
|
|
85
|
+
headers['X-Mipham-Signature'] = signature
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(config.endpoint, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers,
|
|
92
|
+
body,
|
|
93
|
+
signal: AbortSignal.timeout(config.timeout),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const responseBody = await response.text()
|
|
97
|
+
|
|
98
|
+
if (response.status === 200) {
|
|
99
|
+
// Parse verdict — treat any non-deny as allow
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(responseBody) as InferenceCheckResponse
|
|
102
|
+
if (parsed.verdict === 'deny') {
|
|
103
|
+
return { allowed: false, reason: parsed.reason || 'Blocked by DLP policy' }
|
|
104
|
+
}
|
|
105
|
+
return { allowed: true }
|
|
106
|
+
} catch {
|
|
107
|
+
// Unparseable response — treat as allow (server acknowledged receipt)
|
|
108
|
+
return { allowed: true }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 403 = explicit deny
|
|
113
|
+
if (response.status === 403) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(responseBody) as InferenceCheckResponse
|
|
116
|
+
return { allowed: false, reason: parsed.reason || 'Blocked by DLP policy' }
|
|
117
|
+
} catch {
|
|
118
|
+
return { allowed: false, reason: `DLP server denied (403): ${responseBody.slice(0, 200)}` }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Other non-2xx: apply failure posture
|
|
123
|
+
if (config.on_failure === 'fail-closed') {
|
|
124
|
+
return {
|
|
125
|
+
allowed: false,
|
|
126
|
+
reason: `DLP server returned ${response.status}: ${responseBody.slice(0, 200)}`,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { allowed: true }
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// Network error or timeout
|
|
132
|
+
if (config.on_failure === 'fail-closed') {
|
|
133
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
134
|
+
return {
|
|
135
|
+
allowed: false,
|
|
136
|
+
reason: `DLP server unreachable: ${msg}`,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// fail-open: allow through
|
|
140
|
+
return { allowed: true }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if inference hook is configured and should be used.
|
|
146
|
+
*/
|
|
147
|
+
export function isInferenceHookEnabled(config?: InferenceHookConfig): boolean {
|
|
148
|
+
return !!(config?.endpoint && config.endpoint.length > 0)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Internal helpers ──
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Extract tool calls and their results from the entire message history.
|
|
155
|
+
* Scans for tool_use/tool_result pairs across all messages.
|
|
156
|
+
* Each result_preview is truncated to 2000 characters.
|
|
157
|
+
*/
|
|
158
|
+
function extractToolCalls(
|
|
159
|
+
messages: Message[],
|
|
160
|
+
): Array<{ name: string; input: Record<string, unknown>; result_preview: string }> {
|
|
161
|
+
// Collect tool results first (keyed by tool_use_id)
|
|
162
|
+
const toolResults = new Map<string, string>()
|
|
163
|
+
for (const msg of messages) {
|
|
164
|
+
if (Array.isArray(msg.content)) {
|
|
165
|
+
for (const block of msg.content) {
|
|
166
|
+
if (block.type === 'tool_result') {
|
|
167
|
+
toolResults.set(block.tool_use_id, block.content || '')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Extract tool_use blocks with their result previews
|
|
174
|
+
const calls: Array<{
|
|
175
|
+
name: string
|
|
176
|
+
input: Record<string, unknown>
|
|
177
|
+
result_preview: string
|
|
178
|
+
}> = []
|
|
179
|
+
|
|
180
|
+
for (const msg of messages) {
|
|
181
|
+
if (Array.isArray(msg.content)) {
|
|
182
|
+
for (const block of msg.content) {
|
|
183
|
+
if (block.type === 'tool_use') {
|
|
184
|
+
const resultPreview = toolResults.get(block.id) || ''
|
|
185
|
+
calls.push({
|
|
186
|
+
name: block.name,
|
|
187
|
+
input: block.input,
|
|
188
|
+
result_preview: resultPreview.slice(0, 2000),
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return calls
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Sign the request body using HMAC-SHA256.
|
|
200
|
+
* Follows Standard Webhooks specification:
|
|
201
|
+
* X-Mipham-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>
|
|
202
|
+
* Returns empty string if no signing secret is configured.
|
|
203
|
+
*/
|
|
204
|
+
function signPayload(secret: string, body: string): string {
|
|
205
|
+
if (!secret) return ''
|
|
206
|
+
const timestamp = Math.floor(Date.now() / 1000).toString()
|
|
207
|
+
const signedPayload = `${timestamp}.${body}`
|
|
208
|
+
const signature = createHmac('sha256', secret).update(signedPayload).digest('hex')
|
|
209
|
+
return `t=${timestamp},v1=${signature}`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get the current package version for the User-Agent header.
|
|
214
|
+
*/
|
|
215
|
+
function getVersion(): string {
|
|
216
|
+
try {
|
|
217
|
+
// Dynamic import to avoid circular deps — the shared package-info
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
219
|
+
const pkg = require('../../package.json') as { version?: string }
|
|
220
|
+
return pkg.version || '0.0.0'
|
|
221
|
+
} catch {
|
|
222
|
+
return '0.0.0'
|
|
223
|
+
}
|
|
224
|
+
}
|