@miphamai/cli 0.6.9 → 0.7.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/core/engine.ts +69 -10
- package/src/index.tsx +6 -0
- package/src/mcp/client.ts +8 -2
- package/src/plugin/plugin-loader.ts +28 -2
- package/src/plugin/plugin-manager.ts +21 -0
- package/src/providers/anthropic.ts +12 -0
- package/src/providers/openai-compat.ts +17 -6
- package/src/shared/types.ts +13 -2
- package/src/tools/exec/bash.ts +37 -1
- package/src/tools/file/edit.ts +17 -0
- package/src/tools/file/read.ts +9 -0
- package/src/tools/file/write.ts +10 -0
- package/dist/mipham +0 -0
package/package.json
CHANGED
package/src/core/engine.ts
CHANGED
|
@@ -148,6 +148,7 @@ export class QueryEngine {
|
|
|
148
148
|
|
|
149
149
|
let assistantContent = ''
|
|
150
150
|
let reasoningContent = ''
|
|
151
|
+
let thinkingContent = ''
|
|
151
152
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
152
153
|
|
|
153
154
|
// Stream model response
|
|
@@ -174,6 +175,10 @@ export class QueryEngine {
|
|
|
174
175
|
reasoningContent += chunk.reasoning_content
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
if (chunk.type === 'thinking' && chunk.thinking) {
|
|
179
|
+
thinkingContent += chunk.thinking
|
|
180
|
+
}
|
|
181
|
+
|
|
177
182
|
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
178
183
|
toolUses.push({
|
|
179
184
|
id: chunk.toolUse.id,
|
|
@@ -184,10 +189,17 @@ export class QueryEngine {
|
|
|
184
189
|
|
|
185
190
|
if (chunk.type === 'stop') {
|
|
186
191
|
// Add assistant response to context
|
|
187
|
-
if (assistantContent || reasoningContent) {
|
|
192
|
+
if (assistantContent || reasoningContent || thinkingContent) {
|
|
193
|
+
const contentBlocks: import('../shared/types').ContentBlock[] = []
|
|
194
|
+
if (thinkingContent) {
|
|
195
|
+
contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
|
|
196
|
+
}
|
|
197
|
+
if (assistantContent) {
|
|
198
|
+
contentBlocks.push({ type: 'text', text: assistantContent })
|
|
199
|
+
}
|
|
188
200
|
const msg: import('../shared/types').Message = {
|
|
189
201
|
role: 'assistant',
|
|
190
|
-
content: assistantContent || '',
|
|
202
|
+
content: thinkingContent ? contentBlocks : assistantContent || '',
|
|
191
203
|
}
|
|
192
204
|
if (reasoningContent) msg.reasoning_content = reasoningContent
|
|
193
205
|
this.context.addMessage(msg)
|
|
@@ -197,10 +209,17 @@ export class QueryEngine {
|
|
|
197
209
|
} catch (err) {
|
|
198
210
|
if (isAbortError(err)) {
|
|
199
211
|
// User interrupted — keep partial content, stop gracefully
|
|
200
|
-
if (assistantContent || reasoningContent) {
|
|
212
|
+
if (assistantContent || reasoningContent || thinkingContent) {
|
|
213
|
+
const contentBlocks: import('../shared/types').ContentBlock[] = []
|
|
214
|
+
if (thinkingContent) {
|
|
215
|
+
contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
|
|
216
|
+
}
|
|
217
|
+
if (assistantContent) {
|
|
218
|
+
contentBlocks.push({ type: 'text', text: assistantContent })
|
|
219
|
+
}
|
|
201
220
|
const msg: import('../shared/types').Message = {
|
|
202
221
|
role: 'assistant',
|
|
203
|
-
content: assistantContent || '',
|
|
222
|
+
content: thinkingContent ? contentBlocks : assistantContent || '',
|
|
204
223
|
}
|
|
205
224
|
if (reasoningContent) msg.reasoning_content = reasoningContent
|
|
206
225
|
this.context.addMessage(msg)
|
|
@@ -242,7 +261,13 @@ export class QueryEngine {
|
|
|
242
261
|
})
|
|
243
262
|
this.context.addMessage({
|
|
244
263
|
role: 'user',
|
|
245
|
-
content: [
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: 'tool_result',
|
|
267
|
+
tool_use_id: toolUse.id,
|
|
268
|
+
content: result.success ? result.content : result.error || result.content,
|
|
269
|
+
},
|
|
270
|
+
],
|
|
246
271
|
})
|
|
247
272
|
}
|
|
248
273
|
|
|
@@ -282,6 +307,7 @@ export class QueryEngine {
|
|
|
282
307
|
|
|
283
308
|
let assistantContent = ''
|
|
284
309
|
let reasoningContent = ''
|
|
310
|
+
let thinkingContent = ''
|
|
285
311
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
286
312
|
|
|
287
313
|
try {
|
|
@@ -301,6 +327,10 @@ export class QueryEngine {
|
|
|
301
327
|
reasoningContent += chunk.reasoning_content
|
|
302
328
|
}
|
|
303
329
|
|
|
330
|
+
if (chunk.type === 'thinking' && chunk.thinking) {
|
|
331
|
+
thinkingContent += chunk.thinking
|
|
332
|
+
}
|
|
333
|
+
|
|
304
334
|
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
305
335
|
toolUses.push({
|
|
306
336
|
id: chunk.toolUse.id,
|
|
@@ -311,10 +341,17 @@ export class QueryEngine {
|
|
|
311
341
|
}
|
|
312
342
|
} catch (err) {
|
|
313
343
|
if (isAbortError(err)) {
|
|
314
|
-
if (assistantContent || reasoningContent) {
|
|
344
|
+
if (assistantContent || reasoningContent || thinkingContent) {
|
|
345
|
+
const contentBlocks: import('../shared/types').ContentBlock[] = []
|
|
346
|
+
if (thinkingContent) {
|
|
347
|
+
contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
|
|
348
|
+
}
|
|
349
|
+
if (assistantContent) {
|
|
350
|
+
contentBlocks.push({ type: 'text', text: assistantContent })
|
|
351
|
+
}
|
|
315
352
|
const msg: import('../shared/types').Message = {
|
|
316
353
|
role: 'assistant',
|
|
317
|
-
content: assistantContent || '',
|
|
354
|
+
content: thinkingContent ? contentBlocks : assistantContent || '',
|
|
318
355
|
}
|
|
319
356
|
if (reasoningContent) msg.reasoning_content = reasoningContent
|
|
320
357
|
this.context.addMessage(msg)
|
|
@@ -326,15 +363,31 @@ export class QueryEngine {
|
|
|
326
363
|
return
|
|
327
364
|
}
|
|
328
365
|
|
|
329
|
-
if (assistantContent || reasoningContent) {
|
|
366
|
+
if (assistantContent || reasoningContent || thinkingContent) {
|
|
367
|
+
const contentBlocks: import('../shared/types').ContentBlock[] = []
|
|
368
|
+
if (thinkingContent) {
|
|
369
|
+
contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
|
|
370
|
+
}
|
|
371
|
+
if (assistantContent) {
|
|
372
|
+
contentBlocks.push({ type: 'text', text: assistantContent })
|
|
373
|
+
}
|
|
330
374
|
const msg: import('../shared/types').Message = {
|
|
331
375
|
role: 'assistant',
|
|
332
|
-
content: assistantContent || '',
|
|
376
|
+
content: thinkingContent ? contentBlocks : assistantContent || '',
|
|
333
377
|
}
|
|
334
378
|
if (reasoningContent) msg.reasoning_content = reasoningContent
|
|
335
379
|
this.context.addMessage(msg)
|
|
336
380
|
}
|
|
337
381
|
|
|
382
|
+
// Safety: prevent silent truncation when max turns reached
|
|
383
|
+
if (turn === MAX_TURNS - 1 && toolUses.length > 0) {
|
|
384
|
+
yield {
|
|
385
|
+
type: 'error',
|
|
386
|
+
error: `Max tool-calling turns (${MAX_TURNS}) reached. Some tool calls were not executed.`,
|
|
387
|
+
}
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
338
391
|
// No more tool calls — fire Stop hook and potentially continue
|
|
339
392
|
if (toolUses.length === 0) {
|
|
340
393
|
yield* this.checkStopHook(signal)
|
|
@@ -356,7 +409,13 @@ export class QueryEngine {
|
|
|
356
409
|
})
|
|
357
410
|
this.context.addMessage({
|
|
358
411
|
role: 'user',
|
|
359
|
-
content: [
|
|
412
|
+
content: [
|
|
413
|
+
{
|
|
414
|
+
type: 'tool_result',
|
|
415
|
+
tool_use_id: toolUse.id,
|
|
416
|
+
content: result.success ? result.content : result.error || result.content,
|
|
417
|
+
},
|
|
418
|
+
],
|
|
360
419
|
})
|
|
361
420
|
}
|
|
362
421
|
}
|
package/src/index.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { loadSessionMemories } from './core/memory/memory-loader'
|
|
|
8
8
|
import { ContextManager } from './core/context'
|
|
9
9
|
import { QueryEngine } from './core/engine'
|
|
10
10
|
import { SessionStore } from './core/session-store'
|
|
11
|
+
import type { PermissionLevel } from './shared/types'
|
|
11
12
|
import { SkillsLoader } from './skills/loader'
|
|
12
13
|
import { PluginManager } from './plugin/plugin-manager'
|
|
13
14
|
import { loadPlugins } from './plugin/plugin-loader'
|
|
@@ -148,6 +149,11 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
148
149
|
engine.setAgentViewManager(agentViewManager)
|
|
149
150
|
engine.setSkillsLoader(skillsLoader)
|
|
150
151
|
|
|
152
|
+
// Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
|
|
153
|
+
if (config.permission) {
|
|
154
|
+
engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
|
|
155
|
+
}
|
|
156
|
+
|
|
151
157
|
// Initialize agent registry and load plugin agents/skills/MCP/hooks
|
|
152
158
|
const agentRegistry = new AgentRegistry()
|
|
153
159
|
agentRegistry.loadUserAgents()
|
package/src/mcp/client.ts
CHANGED
|
@@ -85,16 +85,22 @@ export class McpClient {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Disconnect an MCP server and return the names of its registered tools
|
|
90
|
+
* so the caller can unregister them from the central tool registry.
|
|
91
|
+
*/
|
|
92
|
+
disconnect(name: string): string[] {
|
|
89
93
|
const conn = this.connections.get(name)
|
|
90
|
-
if (!conn) return
|
|
94
|
+
if (!conn) return []
|
|
91
95
|
|
|
92
96
|
try {
|
|
93
97
|
conn.transport.close()
|
|
94
98
|
} catch {
|
|
95
99
|
/* best effort */
|
|
96
100
|
}
|
|
101
|
+
const toolNames = conn.tools.map((t) => t.name)
|
|
97
102
|
this.connections.delete(name)
|
|
103
|
+
return toolNames
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
async closeAll(): Promise<void> {
|
|
@@ -24,6 +24,9 @@ export function loadPlugins(
|
|
|
24
24
|
toolsMap: Map<string, ToolDefinition>,
|
|
25
25
|
): void {
|
|
26
26
|
for (const plugin of pluginManager.getEnabled()) {
|
|
27
|
+
const mcpServers: string[] = []
|
|
28
|
+
const hookEvents: HookEvent[] = []
|
|
29
|
+
|
|
27
30
|
// ── Custom agents ──
|
|
28
31
|
const agentsDir = join(plugin.path, 'agents')
|
|
29
32
|
if (existsSync(agentsDir)) {
|
|
@@ -59,6 +62,7 @@ export function loadPlugins(
|
|
|
59
62
|
const raw = readFileSync(join(mcpDir, entry), 'utf-8')
|
|
60
63
|
const cfg = JSON.parse(raw) as McpServerConfig
|
|
61
64
|
if (cfg.name && cfg.command) {
|
|
65
|
+
mcpServers.push(cfg.name)
|
|
62
66
|
mcpClient
|
|
63
67
|
.connect(cfg)
|
|
64
68
|
.then(() => {
|
|
@@ -87,8 +91,6 @@ export function loadPlugins(
|
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
// ── Hooks from plugin.json ──
|
|
90
|
-
// Plugin hooks are HookConfig objects (type + command/url), not HookDefinition
|
|
91
|
-
// (which requires a handler function). Convert them via executeHook wrapper.
|
|
92
94
|
try {
|
|
93
95
|
const manifestPath = join(plugin.path, 'plugin.json')
|
|
94
96
|
if (existsSync(manifestPath)) {
|
|
@@ -100,6 +102,7 @@ export function loadPlugins(
|
|
|
100
102
|
| HookEvent
|
|
101
103
|
| undefined
|
|
102
104
|
if (event) {
|
|
105
|
+
hookEvents.push(event)
|
|
103
106
|
hookEngine.register({
|
|
104
107
|
event,
|
|
105
108
|
handler: async (ctx) => executeHook(hookCfg, ctx),
|
|
@@ -112,5 +115,28 @@ export function loadPlugins(
|
|
|
112
115
|
} catch (err) {
|
|
113
116
|
process.stderr.write(`[plugin] Failed to load hooks from "${plugin.name}": ${String(err)}\n`)
|
|
114
117
|
}
|
|
118
|
+
|
|
119
|
+
// ── Register cleanup callback ──
|
|
120
|
+
pluginManager.onRemove(plugin.name, () => {
|
|
121
|
+
// Disconnect MCP servers and unregister their tools
|
|
122
|
+
for (const serverName of mcpServers) {
|
|
123
|
+
try {
|
|
124
|
+
const toolNames = mcpClient.disconnect(serverName)
|
|
125
|
+
for (const toolName of toolNames) {
|
|
126
|
+
toolsMap.delete(`mcp__${serverName}__${toolName}`)
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
/* best effort */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Unregister hooks
|
|
133
|
+
for (const event of hookEvents) {
|
|
134
|
+
try {
|
|
135
|
+
hookEngine.unregister(event)
|
|
136
|
+
} catch {
|
|
137
|
+
/* best effort */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
115
141
|
}
|
|
116
142
|
}
|
|
@@ -18,6 +18,7 @@ export class PluginManager {
|
|
|
18
18
|
private plugins: InstalledPlugin[] = []
|
|
19
19
|
private pluginDir: string
|
|
20
20
|
private statePath: string
|
|
21
|
+
private cleanupCallbacks = new Map<string, () => void>()
|
|
21
22
|
|
|
22
23
|
constructor(pluginDir?: string) {
|
|
23
24
|
this.pluginDir = pluginDir ?? PLUGIN_DIR
|
|
@@ -139,9 +140,29 @@ export class PluginManager {
|
|
|
139
140
|
return [...this.plugins]
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Register a cleanup callback that will be invoked when the named plugin is removed.
|
|
145
|
+
* This allows the plugin loader to tear down hooks, agents, MCP connections, and tools.
|
|
146
|
+
*/
|
|
147
|
+
onRemove(name: string, cleanup: () => void): void {
|
|
148
|
+
this.cleanupCallbacks.set(name, cleanup)
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
remove(name: string): boolean {
|
|
143
152
|
const plugin = this.plugins.find((p) => p.name === name)
|
|
144
153
|
if (!plugin) return false
|
|
154
|
+
|
|
155
|
+
// Run registered cleanup: disconnect MCP, unregister hooks/agents/tools
|
|
156
|
+
const cleanup = this.cleanupCallbacks.get(name)
|
|
157
|
+
if (cleanup) {
|
|
158
|
+
try {
|
|
159
|
+
cleanup()
|
|
160
|
+
} catch {
|
|
161
|
+
/* cleanup is best-effort */
|
|
162
|
+
}
|
|
163
|
+
this.cleanupCallbacks.delete(name)
|
|
164
|
+
}
|
|
165
|
+
|
|
145
166
|
try {
|
|
146
167
|
rmSync(plugin.path, { recursive: true, force: true })
|
|
147
168
|
} catch {
|
|
@@ -11,6 +11,7 @@ import { fetchWithRetry } from './fetch-utils'
|
|
|
11
11
|
interface AnthropicContentBlock {
|
|
12
12
|
type: string
|
|
13
13
|
text?: string
|
|
14
|
+
thinking?: string
|
|
14
15
|
id?: string
|
|
15
16
|
name?: string
|
|
16
17
|
input?: Record<string, unknown>
|
|
@@ -30,6 +31,7 @@ interface AnthropicSSEEvent {
|
|
|
30
31
|
delta?: {
|
|
31
32
|
type: string
|
|
32
33
|
text?: string
|
|
34
|
+
thinking?: string
|
|
33
35
|
partial_json?: string
|
|
34
36
|
}
|
|
35
37
|
error?: { type: string; message: string }
|
|
@@ -136,6 +138,10 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
136
138
|
yield { type: 'text', content: delta.text }
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
if (delta.type === 'thinking_delta' && delta.text) {
|
|
142
|
+
yield { type: 'thinking', thinking: delta.text }
|
|
143
|
+
}
|
|
144
|
+
|
|
139
145
|
if (delta.type === 'input_json_delta' && delta.partial_json) {
|
|
140
146
|
accumulatedToolInput += delta.partial_json
|
|
141
147
|
}
|
|
@@ -250,6 +256,12 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
250
256
|
}
|
|
251
257
|
}
|
|
252
258
|
|
|
259
|
+
case 'thinking':
|
|
260
|
+
return {
|
|
261
|
+
type: 'thinking',
|
|
262
|
+
thinking: block.thinking,
|
|
263
|
+
}
|
|
264
|
+
|
|
253
265
|
case 'tool_use':
|
|
254
266
|
return {
|
|
255
267
|
type: 'tool_use',
|
|
@@ -172,7 +172,7 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
172
172
|
const next: Message | undefined = messages[i + 1]
|
|
173
173
|
if (
|
|
174
174
|
msg.role === 'assistant' &&
|
|
175
|
-
msg.content.length > 0 &&
|
|
175
|
+
(msg.content.length > 0 || msg.reasoning_content) &&
|
|
176
176
|
next &&
|
|
177
177
|
next.role === 'assistant' &&
|
|
178
178
|
typeof next.content !== 'string' &&
|
|
@@ -214,11 +214,12 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
214
214
|
|
|
215
215
|
// ── Assistant tool_use → OpenAI tool_calls ──
|
|
216
216
|
if (toolUses.length > 0 && msg.role === 'assistant') {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
const textParts: string[] = []
|
|
218
|
+
for (const b of blocks) {
|
|
219
|
+
if (b.type === 'text') textParts.push(b.text)
|
|
220
|
+
if (b.type === 'thinking') textParts.push(`[Thinking: ${b.thinking}]`)
|
|
221
|
+
}
|
|
222
|
+
const textContent = textParts.join('') || null
|
|
222
223
|
|
|
223
224
|
const toolUseMsg: Record<string, unknown> = {
|
|
224
225
|
role: 'assistant',
|
|
@@ -241,6 +242,16 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
241
242
|
|
|
242
243
|
// ── Tool result → OpenAI tool role ──
|
|
243
244
|
if (toolResults.length > 0) {
|
|
245
|
+
// Emit companion text blocks as a user message before tool results
|
|
246
|
+
const textContent =
|
|
247
|
+
blocks
|
|
248
|
+
.filter((b) => b.type === 'text')
|
|
249
|
+
.map((b) => (b.type === 'text' ? b.text : ''))
|
|
250
|
+
.join('') || null
|
|
251
|
+
if (textContent) {
|
|
252
|
+
result.push({ role: 'user', content: textContent })
|
|
253
|
+
}
|
|
254
|
+
|
|
244
255
|
for (const tr of toolResults) {
|
|
245
256
|
result.push({
|
|
246
257
|
role: 'tool',
|
package/src/shared/types.ts
CHANGED
|
@@ -41,7 +41,16 @@ export interface ToolResultContent {
|
|
|
41
41
|
tool_use_id: string
|
|
42
42
|
content: string
|
|
43
43
|
}
|
|
44
|
-
export
|
|
44
|
+
export interface ThinkingContent {
|
|
45
|
+
type: 'thinking'
|
|
46
|
+
thinking: string
|
|
47
|
+
}
|
|
48
|
+
export type ContentBlock =
|
|
49
|
+
| TextContent
|
|
50
|
+
| ImageContent
|
|
51
|
+
| ToolUseContent
|
|
52
|
+
| ToolResultContent
|
|
53
|
+
| ThinkingContent
|
|
45
54
|
|
|
46
55
|
export interface Message {
|
|
47
56
|
role: 'system' | 'user' | 'assistant'
|
|
@@ -102,13 +111,15 @@ export interface ToolDefinition {
|
|
|
102
111
|
|
|
103
112
|
// ── Stream Types ──
|
|
104
113
|
export interface StreamChunk {
|
|
105
|
-
type: 'text' | 'tool_use' | 'tool_result' | 'stop' | 'error'
|
|
114
|
+
type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stop' | 'error'
|
|
106
115
|
content?: string
|
|
107
116
|
toolUse?: ToolUseContent
|
|
108
117
|
tool_use_id?: string
|
|
109
118
|
error?: string
|
|
110
119
|
/** DeepSeek reasoning tokens accumulated during this stream. */
|
|
111
120
|
reasoning_content?: string
|
|
121
|
+
/** Anthropic thinking block content (DeepSeek Anthropic endpoint). */
|
|
122
|
+
thinking?: string
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
// ── Config Types ──
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -5,17 +5,46 @@ const BLOCKED_PATTERNS = [
|
|
|
5
5
|
// Recursive root deletion without preserve-root safeguard
|
|
6
6
|
/\brm\s+-rf\s+\/(\s|$)/,
|
|
7
7
|
/\brm\s+-rf\s+\/\*\s*$/,
|
|
8
|
+
/\bsudo\s+rm\s+.*\//,
|
|
8
9
|
// Filesystem manipulation
|
|
9
10
|
/\bmkfs\./,
|
|
10
11
|
/\bdd\s+if=/,
|
|
11
12
|
// Fork bomb
|
|
12
13
|
/:\s*\(\s*\)\s*\{\s*:\s*\|/,
|
|
13
14
|
// Recursive root chmod
|
|
14
|
-
/\bchmod\s+.*777\s+\//,
|
|
15
|
+
/\bchmod\s+.*(?:777|o\+w|a\+w)\s+\//,
|
|
15
16
|
// Direct block device write
|
|
16
17
|
/>\s*\/dev\/sd[a-z]/,
|
|
17
18
|
// SSH private key theft
|
|
18
19
|
/\bcat\s+.*\/\.ssh\/id_/,
|
|
20
|
+
// Shell command substitution (commonly used for obfuscation/evasion)
|
|
21
|
+
/\$\(/,
|
|
22
|
+
// Backtick command substitution
|
|
23
|
+
/`[^`]+`/,
|
|
24
|
+
// Interpreter code execution (bypass vector)
|
|
25
|
+
/\bpython3?\s+-c\b/,
|
|
26
|
+
/\bpython3?\s+-m\b/,
|
|
27
|
+
/\bperl\s+-[ep]\b/,
|
|
28
|
+
/\bruby\s+-e\b/,
|
|
29
|
+
/\bnode\s+-e\b/,
|
|
30
|
+
// Reverse shell patterns
|
|
31
|
+
/\bnc\s+.*-e\b/,
|
|
32
|
+
/\bncat\s+.*-e\b/,
|
|
33
|
+
/\bexec\s+\d+<>/,
|
|
34
|
+
// Download + pipe to interpreter
|
|
35
|
+
/\bcurl\s+.*\|\s*(?:ba)?sh\b/,
|
|
36
|
+
/\bwget\s+.*\|\s*(?:ba)?sh\b/,
|
|
37
|
+
/\bcurl\s+.*\|\s*python/,
|
|
38
|
+
/\bwget\s+.*\|\s*python/,
|
|
39
|
+
// Download + execute
|
|
40
|
+
/\bcurl\s+.*-O\s+\/tmp\/.*\s*&&/,
|
|
41
|
+
/\bwget\s+.*-O\s+\/tmp\/.*\s*&&/,
|
|
42
|
+
// Data exfiltration via curl file://
|
|
43
|
+
/\bcurl\s+file:\/\//,
|
|
44
|
+
// SCP exfiltration of sensitive files
|
|
45
|
+
/\bscp\s+.*(?:\.ssh|\.aws|\.env)/,
|
|
46
|
+
// Write to system paths
|
|
47
|
+
/>\s*\/(?:etc|usr|boot|sys|proc)\//,
|
|
19
48
|
]
|
|
20
49
|
|
|
21
50
|
const BLOCKED_COMMANDS = [
|
|
@@ -28,6 +57,13 @@ const BLOCKED_COMMANDS = [
|
|
|
28
57
|
'mkfs.fat',
|
|
29
58
|
'mkfs.vfat',
|
|
30
59
|
'mkswap',
|
|
60
|
+
'shutdown',
|
|
61
|
+
'reboot',
|
|
62
|
+
'halt',
|
|
63
|
+
'poweroff',
|
|
64
|
+
'init',
|
|
65
|
+
'telinit',
|
|
66
|
+
'systemctl',
|
|
31
67
|
]
|
|
32
68
|
|
|
33
69
|
function isBlocked(command: string): string | null {
|
package/src/tools/file/edit.ts
CHANGED
|
@@ -24,7 +24,24 @@ export const editTool: ToolDefinition = {
|
|
|
24
24
|
const newStr = params.new_string as string
|
|
25
25
|
const replaceAll = params.replace_all as boolean
|
|
26
26
|
|
|
27
|
+
const MAX_FILE_SIZE = 10_000_000 // 10 MB
|
|
28
|
+
const MAX_STRING_SIZE = 5_000_000 // 5 MB
|
|
29
|
+
if (oldStr.length > MAX_STRING_SIZE || newStr.length > MAX_STRING_SIZE) {
|
|
30
|
+
return {
|
|
31
|
+
success: false,
|
|
32
|
+
content: '',
|
|
33
|
+
error: `String too large. Max: 5 MB per string.`,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
const content = readFileSync(filePath, 'utf-8')
|
|
38
|
+
if (content.length > MAX_FILE_SIZE) {
|
|
39
|
+
return {
|
|
40
|
+
success: false,
|
|
41
|
+
content: '',
|
|
42
|
+
error: `File too large (${(content.length / 1e6).toFixed(1)} MB). Max: 10 MB.`,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
28
45
|
|
|
29
46
|
if (replaceAll) {
|
|
30
47
|
if (!content.includes(oldStr)) {
|
package/src/tools/file/read.ts
CHANGED
|
@@ -25,6 +25,15 @@ export const readTool: ToolDefinition = {
|
|
|
25
25
|
if (stat.isDirectory()) {
|
|
26
26
|
return { success: false, content: '', error: `Path is a directory: ${filePath}` }
|
|
27
27
|
}
|
|
28
|
+
// Prevent OOM on single-line files (e.g. 500MB JSON blob)
|
|
29
|
+
const MAX_FILE_SIZE = 50_000_000 // 50 MB
|
|
30
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
31
|
+
return {
|
|
32
|
+
success: false,
|
|
33
|
+
content: '',
|
|
34
|
+
error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
28
37
|
const offset = (params.offset as number) || 0
|
|
29
38
|
const limit = (params.limit as number) || 2000
|
|
30
39
|
const content = readFileSync(filePath, 'utf-8')
|
package/src/tools/file/write.ts
CHANGED
|
@@ -19,6 +19,16 @@ export const writeTool: ToolDefinition = {
|
|
|
19
19
|
async execute(params, ctx) {
|
|
20
20
|
const filePath = resolveSafe(ctx.cwd, params.file_path as string)
|
|
21
21
|
const content = params.content as string
|
|
22
|
+
|
|
23
|
+
const MAX_CONTENT_SIZE = 10_000_000 // 10 MB
|
|
24
|
+
if (content.length > MAX_CONTENT_SIZE) {
|
|
25
|
+
return {
|
|
26
|
+
success: false,
|
|
27
|
+
content: '',
|
|
28
|
+
error: `Content too large (${(content.length / 1e6).toFixed(1)} MB). Max: 10 MB.`,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
23
33
|
writeFileSync(filePath, content, 'utf-8')
|
|
24
34
|
const lines = content.split('\n').length
|
package/dist/mipham
DELETED
|
Binary file
|