@miphamai/cli 0.6.9 → 0.7.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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)
@@ -236,13 +255,21 @@ export class QueryEngine {
236
255
  }
237
256
 
238
257
  // Add tool use + result to context
258
+ // DeepSeek V4 thinking mode requires reasoning_content on every assistant message
239
259
  this.context.addMessage({
240
260
  role: 'assistant',
241
261
  content: [{ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input }],
262
+ reasoning_content: '',
242
263
  })
243
264
  this.context.addMessage({
244
265
  role: 'user',
245
- content: [{ type: 'tool_result', tool_use_id: toolUse.id, content: result.content }],
266
+ content: [
267
+ {
268
+ type: 'tool_result',
269
+ tool_use_id: toolUse.id,
270
+ content: result.success ? result.content : result.error || result.content,
271
+ },
272
+ ],
246
273
  })
247
274
  }
248
275
 
@@ -282,6 +309,7 @@ export class QueryEngine {
282
309
 
283
310
  let assistantContent = ''
284
311
  let reasoningContent = ''
312
+ let thinkingContent = ''
285
313
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
286
314
 
287
315
  try {
@@ -301,6 +329,10 @@ export class QueryEngine {
301
329
  reasoningContent += chunk.reasoning_content
302
330
  }
303
331
 
332
+ if (chunk.type === 'thinking' && chunk.thinking) {
333
+ thinkingContent += chunk.thinking
334
+ }
335
+
304
336
  if (chunk.type === 'tool_use' && chunk.toolUse) {
305
337
  toolUses.push({
306
338
  id: chunk.toolUse.id,
@@ -311,10 +343,17 @@ export class QueryEngine {
311
343
  }
312
344
  } catch (err) {
313
345
  if (isAbortError(err)) {
314
- if (assistantContent || reasoningContent) {
346
+ if (assistantContent || reasoningContent || thinkingContent) {
347
+ const contentBlocks: import('../shared/types').ContentBlock[] = []
348
+ if (thinkingContent) {
349
+ contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
350
+ }
351
+ if (assistantContent) {
352
+ contentBlocks.push({ type: 'text', text: assistantContent })
353
+ }
315
354
  const msg: import('../shared/types').Message = {
316
355
  role: 'assistant',
317
- content: assistantContent || '',
356
+ content: thinkingContent ? contentBlocks : assistantContent || '',
318
357
  }
319
358
  if (reasoningContent) msg.reasoning_content = reasoningContent
320
359
  this.context.addMessage(msg)
@@ -326,15 +365,31 @@ export class QueryEngine {
326
365
  return
327
366
  }
328
367
 
329
- if (assistantContent || reasoningContent) {
368
+ if (assistantContent || reasoningContent || thinkingContent) {
369
+ const contentBlocks: import('../shared/types').ContentBlock[] = []
370
+ if (thinkingContent) {
371
+ contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
372
+ }
373
+ if (assistantContent) {
374
+ contentBlocks.push({ type: 'text', text: assistantContent })
375
+ }
330
376
  const msg: import('../shared/types').Message = {
331
377
  role: 'assistant',
332
- content: assistantContent || '',
378
+ content: thinkingContent ? contentBlocks : assistantContent || '',
333
379
  }
334
380
  if (reasoningContent) msg.reasoning_content = reasoningContent
335
381
  this.context.addMessage(msg)
336
382
  }
337
383
 
384
+ // Safety: prevent silent truncation when max turns reached
385
+ if (turn === MAX_TURNS - 1 && toolUses.length > 0) {
386
+ yield {
387
+ type: 'error',
388
+ error: `Max tool-calling turns (${MAX_TURNS}) reached. Some tool calls were not executed.`,
389
+ }
390
+ return
391
+ }
392
+
338
393
  // No more tool calls — fire Stop hook and potentially continue
339
394
  if (toolUses.length === 0) {
340
395
  yield* this.checkStopHook(signal)
@@ -350,13 +405,21 @@ export class QueryEngine {
350
405
  content: result.content,
351
406
  }
352
407
 
408
+ // DeepSeek V4 thinking mode requires reasoning_content on every assistant message
353
409
  this.context.addMessage({
354
410
  role: 'assistant',
355
411
  content: [{ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input }],
412
+ reasoning_content: '',
356
413
  })
357
414
  this.context.addMessage({
358
415
  role: 'user',
359
- content: [{ type: 'tool_result', tool_use_id: toolUse.id, content: result.content }],
416
+ content: [
417
+ {
418
+ type: 'tool_result',
419
+ tool_use_id: toolUse.id,
420
+ content: result.success ? result.content : result.error || result.content,
421
+ },
422
+ ],
360
423
  })
361
424
  }
362
425
  }
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
- disconnect(name: string): void {
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
  }
@@ -215,9 +221,16 @@ export class AnthropicProvider implements ProviderInstance {
215
221
  if (msg.role === 'system') continue
216
222
 
217
223
  if (typeof msg.content === 'string') {
224
+ const content: unknown[] = [{ type: 'text', text: msg.content }]
225
+ // DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
226
+ // message must contain a thinking block if any message in history does.
227
+ if (msg.role === 'assistant') {
228
+ const thinkingText = (msg as any).reasoning_content || ''
229
+ content.unshift({ type: 'thinking', thinking: thinkingText })
230
+ }
218
231
  result.push({
219
232
  role: msg.role,
220
- content: [{ type: 'text', text: msg.content }],
233
+ content,
221
234
  })
222
235
  } else {
223
236
  const blocks = (msg.content as ContentBlock[]).map((block) => {
@@ -250,6 +263,12 @@ export class AnthropicProvider implements ProviderInstance {
250
263
  }
251
264
  }
252
265
 
266
+ case 'thinking':
267
+ return {
268
+ type: 'thinking',
269
+ thinking: block.thinking,
270
+ }
271
+
253
272
  case 'tool_use':
254
273
  return {
255
274
  type: 'tool_use',
@@ -270,6 +289,11 @@ export class AnthropicProvider implements ProviderInstance {
270
289
  }
271
290
  })
272
291
 
292
+ // DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
293
+ // message must contain a thinking block if any message in history does.
294
+ if (msg.role === 'assistant' && !blocks.some((b: any) => b.type === 'thinking')) {
295
+ blocks.unshift({ type: 'thinking', thinking: '' })
296
+ }
273
297
  result.push({ role: msg.role, content: blocks })
274
298
  }
275
299
  }
@@ -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' &&
@@ -190,9 +190,8 @@ export class OpenAICompatProvider implements ProviderInstance {
190
190
  arguments: JSON.stringify(tu.input),
191
191
  },
192
192
  })),
193
- }
194
- if (msg.reasoning_content) {
195
- combinedMsg.reasoning_content = msg.reasoning_content
193
+ // DeepSeek V4 thinking mode: every assistant message needs reasoning_content
194
+ reasoning_content: msg.reasoning_content || '',
196
195
  }
197
196
  result.push(combinedMsg)
198
197
  i++ // skip the next message (consumed)
@@ -200,8 +199,9 @@ export class OpenAICompatProvider implements ProviderInstance {
200
199
  }
201
200
 
202
201
  const standaloneMsg: Record<string, unknown> = { role: msg.role, content: msg.content }
203
- if (msg.role === 'assistant' && msg.reasoning_content) {
204
- standaloneMsg.reasoning_content = msg.reasoning_content
202
+ // DeepSeek V4 thinking mode: every assistant message needs reasoning_content
203
+ if (msg.role === 'assistant') {
204
+ standaloneMsg.reasoning_content = msg.reasoning_content || ''
205
205
  }
206
206
  result.push(standaloneMsg)
207
207
  continue
@@ -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 textContent =
218
- blocks
219
- .filter((b) => b.type === 'text')
220
- .map((b) => (b.type === 'text' ? b.text : ''))
221
- .join('') || null
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',
@@ -231,9 +232,8 @@ export class OpenAICompatProvider implements ProviderInstance {
231
232
  arguments: JSON.stringify(tu.input),
232
233
  },
233
234
  })),
234
- }
235
- if (msg.reasoning_content) {
236
- toolUseMsg.reasoning_content = msg.reasoning_content
235
+ // DeepSeek V4 thinking mode: every assistant message needs reasoning_content
236
+ reasoning_content: msg.reasoning_content || '',
237
237
  }
238
238
  result.push(toolUseMsg)
239
239
  continue
@@ -241,6 +241,16 @@ export class OpenAICompatProvider implements ProviderInstance {
241
241
 
242
242
  // ── Tool result → OpenAI tool role ──
243
243
  if (toolResults.length > 0) {
244
+ // Emit companion text blocks as a user message before tool results
245
+ const textContent =
246
+ blocks
247
+ .filter((b) => b.type === 'text')
248
+ .map((b) => (b.type === 'text' ? b.text : ''))
249
+ .join('') || null
250
+ if (textContent) {
251
+ result.push({ role: 'user', content: textContent })
252
+ }
253
+
244
254
  for (const tr of toolResults) {
245
255
  result.push({
246
256
  role: 'tool',
@@ -41,7 +41,16 @@ export interface ToolResultContent {
41
41
  tool_use_id: string
42
42
  content: string
43
43
  }
44
- export type ContentBlock = TextContent | ImageContent | ToolUseContent | ToolResultContent
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 ──
@@ -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 {
@@ -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)) {
@@ -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')
@@ -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