@miphamai/cli 0.15.0 → 0.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -11,6 +11,8 @@
11
11
  * const unread = bus.poll('bg-1') // messages addressed TO bg-1
12
12
  */
13
13
 
14
+ export type AgentMessageType = 'message' | 'warning' | 'error'
15
+
14
16
  export interface AgentMessage {
15
17
  id: string
16
18
  from: string
@@ -19,6 +21,7 @@ export interface AgentMessage {
19
21
  message: string
20
22
  timestamp: Date
21
23
  read: boolean
24
+ type: AgentMessageType
22
25
  }
23
26
 
24
27
  export class AgentMessageBus {
@@ -29,7 +32,7 @@ export class AgentMessageBus {
29
32
  * Post a message from one agent to another.
30
33
  * Returns the message ID.
31
34
  */
32
- post(from: string, to: string, summary: string, message: string): string {
35
+ post(from: string, to: string, summary: string, message: string, type: AgentMessageType = 'message'): string {
33
36
  const id = `msg-${++this.idCounter}`
34
37
  this.messages.push({
35
38
  id,
@@ -39,6 +42,7 @@ export class AgentMessageBus {
39
42
  message,
40
43
  timestamp: new Date(),
41
44
  read: false,
45
+ type,
42
46
  })
43
47
  return id
44
48
  }
@@ -91,6 +95,14 @@ export class AgentMessageBus {
91
95
  return this.messages.filter((m) => m.to === agentId && !m.read).length
92
96
  }
93
97
 
98
+ /**
99
+ * Get all unread warning messages for an agent.
100
+ * Does NOT mark them as read — use markRead() for that.
101
+ */
102
+ getWarnings(agentId: string): AgentMessage[] {
103
+ return this.messages.filter((m) => m.to === agentId && m.type === 'warning' && !m.read)
104
+ }
105
+
94
106
  /**
95
107
  * Prune messages older than maxAgeMs. Returns the number pruned.
96
108
  */
@@ -3,6 +3,7 @@ import type { ToolDefinition } from '../shared/index.ts'
3
3
  import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
4
4
  import { createAgentContext } from './agent-context'
5
5
  import { getBackgroundAgentRegistry } from './background-registry'
6
+ import { getMessageBus } from './message-bus'
6
7
  import type { HookEngine } from '../core/hooks'
7
8
  import type { PermissionSystem } from '../core/permission'
8
9
 
@@ -156,6 +157,22 @@ export class SubAgent {
156
157
  // 'inherit' means use parent model
157
158
  const resolvedModel = modelToUse === 'inherit' ? model : modelToUse
158
159
 
160
+ // Validate resolved model exists in registry; fall back to parent model with warning
161
+ let finalModel = resolvedModel
162
+ if (resolvedModel !== model) {
163
+ const modelExists = this.registry.findModel(resolvedModel) !== undefined
164
+ if (!modelExists) {
165
+ const warnMsg = `Warning: model "${resolvedModel}" not found in provider registry. Falling back to "${model}".`
166
+ console.warn(warnMsg)
167
+
168
+ // Post warning to message bus for UI display
169
+ const bus = getMessageBus()
170
+ bus.post('system', 'main', `Sub-agent model fallback: ${resolvedModel} → ${model}`, warnMsg, 'warning')
171
+
172
+ finalModel = model
173
+ }
174
+ }
175
+
159
176
  const chunks: string[] = []
160
177
  const MAX_TOOL_TURNS = options.maxTurns || 5
161
178
 
@@ -173,7 +190,7 @@ export class SubAgent {
173
190
  let turnText = ''
174
191
 
175
192
  for await (const chunk of provider.chat({
176
- model: resolvedModel,
193
+ model: finalModel,
177
194
  messages: currentMessages,
178
195
  systemPrompt: currentSystemPrompt,
179
196
  tools: toolDefs,
@@ -251,7 +268,7 @@ export class SubAgent {
251
268
  cwd: execCwd,
252
269
  sessionId: 'sub-agent',
253
270
  provider: '',
254
- model: resolvedModel,
271
+ model: finalModel,
255
272
  })
256
273
 
257
274
  currentMessages.push({
@@ -13,6 +13,10 @@ export const DEFAULT_CONFIG: MiphamConfig = {
13
13
  defaultModel: 'deepseek-v4-pro',
14
14
  permission: 'auto',
15
15
  providers: DEFAULT_PROVIDERS,
16
+ marketplace: {
17
+ strictKnownMarketplaces: [],
18
+ blockedMarketplaces: [],
19
+ },
16
20
  }
17
21
 
18
22
  export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Lightweight preferences store backed by ~/.mipham/preferences.json.
3
+ * Used for persisting user-level UI state (e.g. last code review effort).
4
+ *
5
+ * NOT for config.yml settings — those belong in the YAML config system.
6
+ * NOT for secrets — this file is plain JSON, not encrypted.
7
+ */
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+
12
+ const PREFS_PATH = join(homedir(), '.mipham', 'preferences.json')
13
+
14
+ function readPrefs(): Record<string, string> {
15
+ try {
16
+ if (!existsSync(PREFS_PATH)) return {}
17
+ const raw = readFileSync(PREFS_PATH, 'utf-8')
18
+ const parsed: unknown = JSON.parse(raw)
19
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
20
+ return parsed as Record<string, string>
21
+ } catch {
22
+ return {}
23
+ }
24
+ }
25
+
26
+ function writePrefs(prefs: Record<string, string>): void {
27
+ try {
28
+ const dir = join(homedir(), '.mipham')
29
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
30
+ writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600, encoding: 'utf-8' })
31
+ } catch {
32
+ // best-effort; never crash because preferences failed to save
33
+ }
34
+ }
35
+
36
+ export function getPreference(key: string, defaultValue: string): string {
37
+ const prefs = readPrefs()
38
+ return prefs[key] ?? defaultValue
39
+ }
40
+
41
+ export function setPreference(key: string, value: string): void {
42
+ const prefs = readPrefs()
43
+ prefs[key] = value
44
+ writePrefs(prefs)
45
+ }
@@ -55,6 +55,15 @@ export class ContextManager {
55
55
 
56
56
  constructor(private config: ContextConfig) {}
57
57
 
58
+ /** Dynamically update the max token limit (e.g., when switching models). */
59
+ updateMaxTokens(maxTokens: number): void {
60
+ this.config.maxTokens = maxTokens
61
+ }
62
+
63
+ getMaxTokens(): number {
64
+ return this.config.maxTokens
65
+ }
66
+
58
67
  /** Set an optional LLM summarizer for intelligent compaction. */
59
68
  setSummarizer(fn: Summarizer): void {
60
69
  this.summarizer = fn
@@ -41,7 +41,7 @@ export class QueryEngine {
41
41
  private registry: ProviderRegistry,
42
42
  private context: ContextManager,
43
43
  private tools: Map<string, ToolDefinition>,
44
- private permission: PermissionSystem = new PermissionSystem('bypass'),
44
+ private permission: PermissionSystem = new PermissionSystem('default'),
45
45
  ) {}
46
46
 
47
47
  /** Register a hook engine for pre/post tool-use lifecycle events. */
@@ -821,6 +821,17 @@ export class QueryEngine {
821
821
 
822
822
  switchProvider(providerId: string, modelId?: string): void {
823
823
  this.registry.switchProvider(providerId, modelId)
824
+ // Update context manager's max tokens to match the new model's context window
825
+ if (modelId) {
826
+ const model = this.registry.findModel(modelId)
827
+ if (model) {
828
+ const DISABLE_1M = process.env.MIPHAM_DISABLE_1M_CONTEXT === '1'
829
+ const maxTokens = (DISABLE_1M && model.contextWindow > 200_000)
830
+ ? 200_000
831
+ : model.contextWindow
832
+ this.context.updateMaxTokens(maxTokens)
833
+ }
834
+ }
824
835
  }
825
836
 
826
837
  /** Wrap context compaction with PreCompact/PostCompact hooks. */
@@ -1,4 +1,4 @@
1
- import type { PermissionConfig, PermissionMode } from '../shared/index.ts'
1
+ import type { PermissionConfig, PermissionMode, PermissionRestrictions } from '../shared/index.ts'
2
2
 
3
3
  const DEFAULT_CONFIG: PermissionConfig = {
4
4
  mode: 'default',
@@ -15,11 +15,15 @@ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): Permi
15
15
  mode: (raw.mode as PermissionMode) || DEFAULT_CONFIG.mode,
16
16
  allow: Array.isArray(raw.allow) ? raw.allow : [...DEFAULT_CONFIG.allow],
17
17
  deny: Array.isArray(raw.deny) ? raw.deny : [...DEFAULT_CONFIG.deny],
18
+ restrictions: raw.restrictions ?? undefined,
18
19
  }
19
20
  }
20
21
 
21
- /** Valid mode transition order for Shift+Tab cycling. */
22
- export const MODE_CYCLE: PermissionMode[] = [
22
+ /**
23
+ * Permission modes ordered from least to most permissive.
24
+ * Used to enforce maxAllowedMode: any mode ranked higher than the cap is forbidden.
25
+ */
26
+ export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
23
27
  'default',
24
28
  'acceptEdits',
25
29
  'plan',
@@ -28,7 +32,67 @@ export const MODE_CYCLE: PermissionMode[] = [
28
32
  'bypassPermissions',
29
33
  ]
30
34
 
31
- export function nextMode(current: PermissionMode): PermissionMode {
32
- const idx = MODE_CYCLE.indexOf(current)
33
- return MODE_CYCLE[(idx + 1) % MODE_CYCLE.length]!
35
+ /** Valid mode transition order for Shift+Tab cycling. */
36
+ export const MODE_CYCLE: PermissionMode[] = [...PERMISSION_MODE_HIERARCHY]
37
+
38
+ /** Resolve which modes are actually permitted given the restrictions. */
39
+ export function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
40
+ let allowed = [...MODE_CYCLE]
41
+
42
+ if (restrictions?.forbiddenModes && restrictions.forbiddenModes.length > 0) {
43
+ const forbidden = new Set(restrictions.forbiddenModes)
44
+ allowed = allowed.filter((m) => !forbidden.has(m))
45
+ }
46
+
47
+ if (restrictions?.maxAllowedMode) {
48
+ const capIdx = PERMISSION_MODE_HIERARCHY.indexOf(restrictions.maxAllowedMode)
49
+ if (capIdx >= 0) {
50
+ allowed = allowed.filter((m) => PERMISSION_MODE_HIERARCHY.indexOf(m) <= capIdx)
51
+ }
52
+ }
53
+
54
+ return allowed
55
+ }
56
+
57
+ /** Check whether a given mode is permitted under the restrictions. */
58
+ export function isModeAllowed(
59
+ mode: PermissionMode,
60
+ restrictions?: PermissionRestrictions,
61
+ ): boolean {
62
+ return getAllowedModes(restrictions).includes(mode)
63
+ }
64
+
65
+ /**
66
+ * Return the highest allowed mode at or below `desired` given the restrictions.
67
+ * Used to silently downgrade when a forbidden mode is requested.
68
+ */
69
+ export function clampMode(
70
+ desired: PermissionMode,
71
+ restrictions?: PermissionRestrictions,
72
+ ): PermissionMode {
73
+ const allowed = getAllowedModes(restrictions)
74
+ if (allowed.includes(desired)) return desired
75
+
76
+ // Walk downward through the hierarchy to find the closest allowed mode
77
+ const desiredIdx = PERMISSION_MODE_HIERARCHY.indexOf(desired)
78
+ for (let i = desiredIdx - 1; i >= 0; i--) {
79
+ const candidate = PERMISSION_MODE_HIERARCHY[i]!
80
+ if (allowed.includes(candidate)) return candidate
81
+ }
82
+
83
+ // Fallback: return the first allowed mode (should always be at least 'default')
84
+ return allowed[0] ?? 'default'
85
+ }
86
+
87
+ export function nextMode(
88
+ current: PermissionMode,
89
+ restrictions?: PermissionRestrictions,
90
+ ): PermissionMode {
91
+ const allowed = getAllowedModes(restrictions)
92
+ const idx = allowed.indexOf(current)
93
+ if (idx === -1) {
94
+ // Current mode is not in the allowed set — clamp then find next
95
+ return clampMode(current, restrictions)
96
+ }
97
+ return allowed[(idx + 1) % allowed.length]!
34
98
  }
@@ -3,10 +3,11 @@ import type {
3
3
  PermissionMode,
4
4
  PermissionLevel,
5
5
  PermissionRule,
6
+ PermissionRestrictions,
6
7
  } from '../shared/index.ts'
7
8
  import type { PermissionRuleEntry } from '../shared/index.ts'
8
9
  import { matchBashRule, compileRule } from './permission-rules'
9
- import { loadPermissionConfig, nextMode, MODE_CYCLE } from './permission-config'
10
+ import { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
10
11
 
11
12
  const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
12
13
 
@@ -25,6 +26,9 @@ export class PermissionSystem {
25
26
  private checkCache = new Map<string, PermissionLevel>()
26
27
  private cacheMode: PermissionMode | null = null
27
28
 
29
+ // ── Org-level restrictions (P0 security) ──
30
+ private restrictions: PermissionRestrictions | undefined = undefined
31
+
28
32
  /** Invalidate the permission cache (called on any rule/mode change). */
29
33
  private invalidateCache(): void {
30
34
  this.checkCache.clear()
@@ -44,7 +48,7 @@ export class PermissionSystem {
44
48
  // ── Mode management ──
45
49
 
46
50
  setMode(mode: PermissionMode): void {
47
- this.mode = mode
51
+ this.mode = clampMode(mode, this.restrictions)
48
52
  this.invalidateCache()
49
53
  }
50
54
 
@@ -53,10 +57,26 @@ export class PermissionSystem {
53
57
  }
54
58
 
55
59
  cycleMode(): PermissionMode {
56
- this.mode = nextMode(this.mode)
60
+ this.mode = nextMode(this.mode, this.restrictions)
57
61
  return this.mode
58
62
  }
59
63
 
64
+ // ── Restrictions (P0: org-level policy gap) ──
65
+
66
+ /** Apply org-level permission restrictions. Overwrites any previous restrictions. */
67
+ setRestrictions(restrictions: PermissionRestrictions | undefined): void {
68
+ this.restrictions = restrictions
69
+ // Re-clamp current mode against new restrictions
70
+ if (restrictions) {
71
+ this.mode = clampMode(this.mode, restrictions)
72
+ }
73
+ this.invalidateCache()
74
+ }
75
+
76
+ getRestrictions(): PermissionRestrictions | undefined {
77
+ return this.restrictions
78
+ }
79
+
60
80
  // ── Rule management ──
61
81
 
62
82
  allow(rule: string): void {
@@ -71,11 +91,21 @@ export class PermissionSystem {
71
91
  this.askRules.push(compileRule(rule, 'ask'))
72
92
  }
73
93
 
74
- loadConfig(raw: { mode?: string; allow?: string[]; deny?: string[] }): void {
94
+ loadConfig(raw: {
95
+ mode?: string
96
+ allow?: string[]
97
+ deny?: string[]
98
+ restrictions?: PermissionRestrictions
99
+ }): void {
75
100
  const config = loadPermissionConfig(
76
- raw as Partial<{ mode: PermissionMode; allow: string[]; deny: string[] }>,
101
+ raw as Partial<{
102
+ mode: PermissionMode
103
+ allow: string[]
104
+ deny: string[]
105
+ restrictions: PermissionRestrictions
106
+ }>,
77
107
  )
78
- this.mode = config.mode
108
+ this.mode = clampMode(config.mode, config.restrictions ?? this.restrictions)
79
109
 
80
110
  this.allowRules = []
81
111
  this.denyRules = []
@@ -88,6 +118,10 @@ export class PermissionSystem {
88
118
  this.denyRules.push(compileRule(rule, 'deny'))
89
119
  }
90
120
 
121
+ if (config.restrictions) {
122
+ this.restrictions = config.restrictions
123
+ }
124
+
91
125
  this.invalidateCache()
92
126
  }
93
127
 
@@ -246,9 +280,11 @@ export class PermissionSystem {
246
280
 
247
281
  setDefaultLevel(level: PermissionLevel): void {
248
282
  // Map legacy 3-level to new mode
249
- if (level === 'auto') this.mode = 'auto'
250
- else if (level === 'bypass') this.mode = 'bypassPermissions'
251
- else this.mode = 'default'
283
+ let newMode: PermissionMode
284
+ if (level === 'auto') newMode = 'auto'
285
+ else if (level === 'bypass') newMode = 'bypassPermissions'
286
+ else newMode = 'default'
287
+ this.mode = clampMode(newMode, this.restrictions)
252
288
  this.invalidateCache()
253
289
  }
254
290
 
@@ -17,6 +17,7 @@ export interface SessionMetadata {
17
17
  provider: string
18
18
  model: string
19
19
  messageCount: number
20
+ cwd?: string
20
21
  }
21
22
 
22
23
  export interface StoredSession {
@@ -44,7 +45,7 @@ export class SessionStore {
44
45
  static save(
45
46
  name: string,
46
47
  messages: Message[],
47
- metadata?: { provider?: string; model?: string },
48
+ metadata?: { provider?: string; model?: string; cwd?: string },
48
49
  ): void {
49
50
  ensureDir()
50
51
  const path = filePath(name)
@@ -57,6 +58,7 @@ export class SessionStore {
57
58
  provider: metadata?.provider || 'unknown',
58
59
  model: metadata?.model || 'unknown',
59
60
  messageCount: messages.length,
61
+ cwd: metadata?.cwd,
60
62
  },
61
63
  messages,
62
64
  }
@@ -132,7 +134,7 @@ export class SessionStore {
132
134
  /**
133
135
  * Auto-save with timestamp-based name.
134
136
  */
135
- static autoSave(messages: Message[], metadata?: { provider?: string; model?: string }): string {
137
+ static autoSave(messages: Message[], metadata?: { provider?: string; model?: string; cwd?: string }): string {
136
138
  const name = `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
137
139
  SessionStore.save(name, messages, metadata)
138
140
  return name
package/src/index.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path'
2
+ import { existsSync } from 'node:fs'
2
3
  import { render } from 'ink'
3
4
  import { App } from './ui/app'
4
5
  import { loadConfig, loadInferenceHookConfig, loadCredentialMaskingConfig } from './config/loader'
@@ -85,12 +86,52 @@ export async function runApp(options: RunOptions): Promise<void> {
85
86
  // Initialize plugin manager
86
87
  const pluginManager = new PluginManager()
87
88
 
89
+ // Generate session name for tracking (used by /cd to persist cwd)
90
+ const sessionName = options.resume || `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
91
+
88
92
  // Initialize context — restore saved session if available
89
- const context = new ContextManager({ maxTokens: 200_000, compactionThreshold: 0.9 })
93
+ // Read the active model's context window for dynamic max-token sizing.
94
+ // MIPHAM_DISABLE_1M_CONTEXT=1 caps the effective window at 200K even for
95
+ // models that support larger contexts (e.g. 1M).
96
+ // When the model is unknown (not in any provider's model list), assume a
97
+ // conservative 128K context window. Set MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1
98
+ // to restore the old behavior (default 200K fallback for unknown models).
99
+ const activeModel = registry.findModel(defaultModel)
100
+ let modelContextWindow: number
101
+ if (activeModel) {
102
+ modelContextWindow = activeModel.contextWindow
103
+ } else if (process.env.MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT === '1') {
104
+ modelContextWindow = 200_000 // old behavior: default fallback
105
+ } else {
106
+ modelContextWindow = 128_000 // conservative assumption for unknown models
107
+ console.error(
108
+ `[mipham] ⚠ Unknown model "${defaultModel}": assuming 128K context window. ` +
109
+ `Set MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1 to disable.`,
110
+ )
111
+ }
112
+
113
+ const DISABLE_1M = process.env.MIPHAM_DISABLE_1M_CONTEXT === '1'
114
+ const contextMaxTokens = (DISABLE_1M && modelContextWindow > 200_000)
115
+ ? 200_000
116
+ : modelContextWindow
117
+
118
+ if (DISABLE_1M && modelContextWindow <= 200_000) {
119
+ console.error('[mipham] ⚠ MIPHAM_DISABLE_1M_CONTEXT is set but auto-compaction is not holding the session to 200K — model context window is already ≤ 200K')
120
+ }
121
+
122
+ const context = new ContextManager({ maxTokens: contextMaxTokens, compactionThreshold: 0.9 })
90
123
 
91
124
  if (options.resume) {
92
125
  const saved = SessionStore.load(options.resume)
93
126
  if (saved) {
127
+ // Restore working directory if saved
128
+ if (saved.metadata.cwd && existsSync(saved.metadata.cwd)) {
129
+ try {
130
+ process.chdir(saved.metadata.cwd)
131
+ } catch {
132
+ // cwd may no longer exist; silently continue
133
+ }
134
+ }
94
135
  for (const msg of saved.messages) {
95
136
  context.addMessage(msg)
96
137
  }
@@ -169,6 +210,11 @@ export async function runApp(options: RunOptions): Promise<void> {
169
210
  engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
170
211
  }
171
212
 
213
+ // Apply org-level permission restrictions (P0: bypassPermissions policy gap)
214
+ if (config.permissionRestrictions) {
215
+ engine.getPermission().setRestrictions(config.permissionRestrictions)
216
+ }
217
+
172
218
  // Initialize agent registry and load plugin agents/skills/MCP/hooks
173
219
  const agentRegistry = new AgentRegistry()
174
220
  agentRegistry.loadUserAgents()
@@ -190,9 +236,10 @@ export async function runApp(options: RunOptions): Promise<void> {
190
236
  const saveAndExit = () => {
191
237
  artifactServer.stop()
192
238
  if (context.getMessageCount() > 0) {
193
- SessionStore.autoSave(context.getMessages(), {
239
+ SessionStore.save(sessionName, context.getMessages(), {
194
240
  provider: defaultProvider,
195
241
  model: defaultModel,
242
+ cwd: process.cwd(),
196
243
  })
197
244
  }
198
245
  process.exit(0)
@@ -211,6 +258,7 @@ export async function runApp(options: RunOptions): Promise<void> {
211
258
  skillsLoader={skillsLoader}
212
259
  pluginManager={pluginManager}
213
260
  version={options.version}
261
+ sessionId={sessionName}
214
262
  />,
215
263
  )
216
264
  await waitUntilExit()
@@ -64,6 +64,14 @@ export class ProviderRegistry {
64
64
  return provider.config.models.filter((m) => m.status === 'active')
65
65
  }
66
66
 
67
+ findModel(modelId: string): ModelInfo | undefined {
68
+ for (const provider of this.providers.values()) {
69
+ const model = provider.config.models.find((m) => m.id === modelId)
70
+ if (model) return model
71
+ }
72
+ return undefined
73
+ }
74
+
67
75
  async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
68
76
  const provider = this.getActive()
69
77
  yield* provider.chat({ ...req, model: req.model || this.activeModelId })
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Unicode sanitization for tool inputs.
3
+ * Strips invisible/control characters that could hide command content
4
+ * from visual inspection or enable homoglyph attacks.
5
+ *
6
+ * Claude Code v2.1.223 parity: hidden command text via tabs/invisible Unicode.
7
+ */
8
+
9
+ const DANGEROUS_UNICODE = /[​‌‍‎‏‪‫‬‭‮⁠⁦⁧⁨⁩]/g
10
+
11
+ /**
12
+ * Strip dangerous invisible Unicode characters from a string.
13
+ * - Zero-width: U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+200E/F (LTR/RTL marks)
14
+ * - Bidi controls: U+202A-E, U+2066-9
15
+ * - Word joiner: U+2060
16
+ * - BOM: U+FEFF
17
+ */
18
+ export function stripDangerousUnicode(input: string): string {
19
+ if (!input) return input
20
+ return input.replace(DANGEROUS_UNICODE, '')
21
+ }
22
+
23
+ /**
24
+ * Recursively sanitize all string values in a params object.
25
+ */
26
+ export function sanitizeParams(params: Record<string, unknown>): Record<string, unknown> {
27
+ const result: Record<string, unknown> = {}
28
+ for (const [key, value] of Object.entries(params)) {
29
+ if (typeof value === 'string') {
30
+ result[key] = stripDangerousUnicode(value)
31
+ } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
32
+ result[key] = sanitizeParams(value as Record<string, unknown>)
33
+ } else {
34
+ result[key] = value
35
+ }
36
+ }
37
+ return result
38
+ }
@@ -160,8 +160,16 @@ export interface MiphamConfig {
160
160
  defaultProvider: string
161
161
  defaultModel: string
162
162
  permission: ToolPermission
163
+ /** Org-level permission restrictions (forbiddenModes, maxAllowedMode). */
164
+ permissionRestrictions?: PermissionRestrictions
163
165
  providers: ProviderConfig[]
164
166
  skills?: { paths: string[]; mcpServers: McpServerConfig[] }
167
+ marketplace?: {
168
+ /** If set, only allow installs from matching repos (e.g. ["One-Mipham/*"]) */
169
+ strictKnownMarketplaces?: string[]
170
+ /** Block installs from matching repos (e.g. ["malicious-org/*"]) */
171
+ blockedMarketplaces?: string[]
172
+ }
165
173
  }
166
174
 
167
175
  export interface McpServerConfig {
@@ -287,10 +295,20 @@ export type PermissionMode =
287
295
  /** Backward-compatible alias: PermissionMode plus legacy 'ask' and 'bypass' */
288
296
  export type PermissionLevel = PermissionMode | 'ask' | 'bypass'
289
297
 
298
+ /** Org-level restrictions that cap or forbid specific permission modes. */
299
+ export interface PermissionRestrictions {
300
+ /** Modes that may not be entered (cycle skips them). */
301
+ forbiddenModes?: PermissionMode[]
302
+ /** Ceiling — modes ranked higher (more permissive) than this are treated as forbidden. */
303
+ maxAllowedMode?: PermissionMode
304
+ }
305
+
290
306
  export interface PermissionConfig {
291
307
  mode: PermissionMode
292
308
  allow: string[]
293
309
  deny: string[]
310
+ /** Optional org-level restrictions enforced on every mode transition. */
311
+ restrictions?: PermissionRestrictions
294
312
  }
295
313
 
296
314
  export interface PermissionRuleEntry {
@@ -10,6 +10,7 @@ import { join } from 'node:path'
10
10
  import { homedir } from 'node:os'
11
11
  import { spawnSync } from 'node:child_process'
12
12
  import { URL } from 'node:url'
13
+ import type { MiphamConfig } from '../shared/types.js'
13
14
 
14
15
  const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
15
16
 
@@ -141,13 +142,58 @@ export function searchSkills(query: string): SkillEntry[] {
141
142
  )
142
143
  }
143
144
 
145
+ /**
146
+ * Match a marketplace pattern against an owner/repo.
147
+ *
148
+ * - "owner/*" → matches any repo under that owner
149
+ * - "owner/repo" → exact match
150
+ */
151
+ function matchOwnerPattern(pattern: string, owner: string, repo: string): boolean {
152
+ if (pattern.endsWith('/*')) {
153
+ return pattern.slice(0, -2) === owner
154
+ }
155
+ return pattern === repo
156
+ }
157
+
158
+ /**
159
+ * Check whether a GitHub URL (or any URL) is allowed by the marketplace config.
160
+ *
161
+ * Rules (in order):
162
+ * 1. No config → allow everything
163
+ * 2. blockedMarketplaces matched → deny (deny wins over allow)
164
+ * 3. strictKnownMarketplaces set → deny unless matched
165
+ * 4. Non-GitHub URL with no strict list → allow
166
+ * 5. Non-GitHub URL with strict list → deny
167
+ */
168
+ function isMarketplaceAllowed(url: string, config?: MiphamConfig['marketplace']): boolean {
169
+ if (!config) return true
170
+ const { strictKnownMarketplaces, blockedMarketplaces } = config
171
+ const match = url.match(/github\.com\/([^/]+\/[^/]+)/)
172
+ if (!match) return !strictKnownMarketplaces?.length // non-GitHub: allow only if no strict list
173
+ const repo = match[1]! // "owner/repo"
174
+ const [owner] = repo.split('/')
175
+
176
+ // Check blocked first (deny wins)
177
+ if (blockedMarketplaces?.some((pattern) => matchOwnerPattern(pattern, owner!, repo))) return false
178
+ // Check strict allowlist
179
+ if (
180
+ strictKnownMarketplaces?.length &&
181
+ !strictKnownMarketplaces.some((pattern) => matchOwnerPattern(pattern, owner!, repo))
182
+ )
183
+ return false
184
+ return true
185
+ }
186
+
144
187
  /**
145
188
  * Install a skill from a GitHub repository.
146
189
  *
147
190
  * Clones the repo to a temp directory, copies the skill file(s),
148
191
  * and cleans up.
149
192
  */
150
- export function installSkill(skillName: string): InstallResult {
193
+ export function installSkill(
194
+ skillName: string,
195
+ marketplaceConfig?: MiphamConfig['marketplace'],
196
+ ): InstallResult {
151
197
  const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
152
198
  if (!entry) {
153
199
  return {
@@ -157,6 +203,15 @@ export function installSkill(skillName: string): InstallResult {
157
203
  }
158
204
  }
159
205
 
206
+ // Check marketplace restrictions
207
+ if (!isMarketplaceAllowed(entry.url, marketplaceConfig)) {
208
+ return {
209
+ success: false,
210
+ name: skillName,
211
+ message: `Skill "${skillName}" is from a blocked or unapproved marketplace: ${entry.url}`,
212
+ }
213
+ }
214
+
160
215
  // Built-in skills are already available — no download needed
161
216
  if (entry.builtin) {
162
217
  return {
@@ -214,7 +269,19 @@ export function installSkill(skillName: string): InstallResult {
214
269
  /**
215
270
  * Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
216
271
  */
217
- export function installSkillFromUrl(url: string): InstallResult {
272
+ export function installSkillFromUrl(
273
+ url: string,
274
+ marketplaceConfig?: MiphamConfig['marketplace'],
275
+ ): InstallResult {
276
+ // Check marketplace restrictions
277
+ if (!isMarketplaceAllowed(url, marketplaceConfig)) {
278
+ return {
279
+ success: false,
280
+ name: url,
281
+ message: `URL is from a blocked or unapproved marketplace: ${url}`,
282
+ }
283
+ }
284
+
218
285
  // Derive skill name from URL
219
286
  const name =
220
287
  url
@@ -52,6 +52,18 @@ const BLOCKED_PATTERNS = [
52
52
  /\bscp\s+.*(?:\.ssh|\.aws|\.env)/,
53
53
  // Write to system paths
54
54
  />\s*\/(?:etc|usr|boot|sys|proc)\//,
55
+ // P0 hardening — ANSI-C quoting bypass (e.g. $'\x72\x6d' = rm)
56
+ /\$'\\x[0-9a-fA-F]{2}/,
57
+ // P0 hardening — nested interpreter invocation
58
+ /\b(?:bash|sh|zsh|dash|ksh)\s+-c\b/,
59
+ // P0 hardening — eval builtin (obfuscation vector)
60
+ /\beval\s+/,
61
+ // P0 hardening — exec redirect bypass (e.g. exec >/dev/sda)
62
+ /\bexec\s+\d*>/,
63
+ // P0 hardening — source/dot builtin (script sourcing)
64
+ /\bsource\s+/,
65
+ // P0 hardening — base64 decode + pipe
66
+ /\bbase64\s+(?:-d|--decode)\b/,
55
67
  ]
56
68
 
57
69
  const BLOCKED_COMMANDS = [
@@ -71,18 +83,38 @@ const BLOCKED_COMMANDS = [
71
83
  'init',
72
84
  'telinit',
73
85
  'systemctl',
86
+ 'eval',
87
+ 'exec',
88
+ 'source',
89
+ '.',
74
90
  ]
75
91
 
92
+ /**
93
+ * Normalize ANSI-C escape sequences ($'...') in a command string.
94
+ * Converts hex escapes (\xHH) back to literal characters so that
95
+ * existing patterns (e.g. rm -rf /) still catch obfuscated payloads.
96
+ */
97
+ function normalizeEscapes(command: string): string {
98
+ return command.replace(/\$'([^']*)'/g, (_, inner: string) =>
99
+ inner.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
100
+ String.fromCharCode(parseInt(hex, 16)),
101
+ ),
102
+ )
103
+ }
104
+
76
105
  function isBlocked(command: string): string | null {
77
- // Check exact blocked commands
78
- const firstWord = command.trim().split(/\s+/)[0]
106
+ // Normalize ANSI-C escape sequences for defense-in-depth
107
+ const normalized = normalizeEscapes(command)
108
+
109
+ // Check exact blocked commands (on normalized command)
110
+ const firstWord = normalized.trim().split(/\s+/)[0]
79
111
  if (firstWord && BLOCKED_COMMANDS.includes(firstWord)) {
80
- return `Command "${firstWord}" is blocked (destructive filesystem operation).`
112
+ return `Command "${firstWord}" rejected by security policy.`
81
113
  }
82
114
 
83
- // Check dangerous patterns
115
+ // Check dangerous patterns (on both original and normalized)
84
116
  for (const pattern of BLOCKED_PATTERNS) {
85
- if (pattern.test(command)) {
117
+ if (pattern.test(command) || pattern.test(normalized)) {
86
118
  return `Command rejected by security policy. Pattern matched: ${pattern.source.slice(0, 40)}...`
87
119
  }
88
120
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition, ToolResult } from '../shared/index.ts'
2
+ import { sanitizeParams } from '../shared/sanitize'
2
3
  import { readTool } from './file/read'
3
4
  import { writeTool } from './file/write'
4
5
  import { editTool } from './file/edit'
@@ -96,11 +97,13 @@ function withValidation(tool: ToolDefinition): ToolDefinition {
96
97
  return {
97
98
  ...tool,
98
99
  async execute(params, ctx): Promise<ToolResult> {
99
- const errors = validateParams(schema, params)
100
+ // Sanitize dangerous Unicode from all inputs
101
+ const cleanParams = sanitizeParams(params)
102
+ const errors = validateParams(schema, cleanParams)
100
103
  if (errors.length > 0) {
101
104
  return { success: false, content: '', error: `Invalid parameters: ${errors.join('; ')}` }
102
105
  }
103
- return tool.execute(params, ctx)
106
+ return tool.execute(cleanParams, ctx)
104
107
  },
105
108
  }
106
109
  }
package/src/ui/app.tsx CHANGED
@@ -1,9 +1,10 @@
1
- import React, { useState, useCallback, useRef, useMemo } from 'react'
1
+ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import type { QueryEngine } from '../core/engine'
4
4
  import type { MiphamConfig } from '../shared/index.ts'
5
5
  import type { SkillsLoader } from '../skills/loader'
6
6
  import type { PluginManager } from '../plugin/plugin-manager'
7
+ import { getPreference, setPreference } from '../config/preferences'
7
8
  import { AgentRegistry } from '../agent/agent-registry'
8
9
  import { ChatPanel } from './chat'
9
10
  import { InputBar } from './input'
@@ -26,6 +27,7 @@ interface AppProps {
26
27
  skillsLoader?: SkillsLoader
27
28
  pluginManager?: PluginManager
28
29
  version?: string
30
+ sessionId?: string
29
31
  }
30
32
 
31
33
  export interface ToolMeta {
@@ -84,6 +86,7 @@ export function App({
84
86
  skillsLoader,
85
87
  pluginManager,
86
88
  version,
89
+ sessionId,
87
90
  }: AppProps) {
88
91
  const [messages, setMessages] = useState<ChatMessage[]>([])
89
92
  const [isLoading, setIsLoading] = useState(false)
@@ -100,6 +103,12 @@ export function App({
100
103
  const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
101
104
  const [agentElapsed, setAgentElapsed] = useState(0)
102
105
 
106
+ // Load persisted effort on mount
107
+ useEffect(() => {
108
+ const saved = getPreference('lastCodeReviewEffort', 'high')
109
+ setEffort(saved)
110
+ }, [])
111
+
103
112
  // Agent elapsed timer
104
113
  React.useEffect(() => {
105
114
  if (!agentProgress) {
@@ -131,15 +140,19 @@ export function App({
131
140
  providerId,
132
141
  modelId,
133
142
  version: version || '0.0.0',
143
+ sessionId: sessionId || '',
134
144
  setSessionTitle: (title: string) => setSessionTitle(title),
135
145
  setFastMode: (on: boolean) => setFastMode(on),
136
- setEffort: (level: string) => setEffort(level),
146
+ setEffort: (level: string) => {
147
+ setEffort(level)
148
+ setPreference('lastCodeReviewEffort', level)
149
+ },
137
150
  setFocusMode: (on: boolean) => setFocusMode(on),
138
151
  setGoal: (text: string) => setGoalText(text),
139
152
  skillsLoader,
140
153
  pluginManager,
141
154
  }),
142
- [engine, config, providerId, modelId, skillsLoader, pluginManager],
155
+ [engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId],
143
156
  )
144
157
 
145
158
  const handleSubmit = useCallback(
@@ -10,6 +10,7 @@ import type { SkillsLoader } from '../skills/loader'
10
10
  import type { PluginManager } from '../plugin/plugin-manager'
11
11
  import { McpClient } from '../mcp/client'
12
12
  import { NPM_INSTALL_COMMAND, NPM_UPDATE_COMMAND, PACKAGE_VERSION } from '../shared/index.ts'
13
+ import { getPreference } from '../config/preferences'
13
14
 
14
15
  export interface CommandContext {
15
16
  engine: QueryEngine
@@ -17,6 +18,7 @@ export interface CommandContext {
17
18
  providerId: string
18
19
  modelId: string
19
20
  version: string
21
+ sessionId: string
20
22
  // Callbacks for commands that mutate App state
21
23
  setSessionTitle: (title: string) => void
22
24
  setFastMode: (on: boolean) => void
@@ -799,7 +801,7 @@ const browseSkillsCmd: CommandHandler = async () => {
799
801
  return { content: lines.join('\n') }
800
802
  }
801
803
 
802
- const installSkillCmd: CommandHandler = async (_ctx, args) => {
804
+ const installSkillCmd: CommandHandler = async (ctx, args) => {
803
805
  const { installSkill, installSkillFromUrl } = await import('../skills/registry')
804
806
 
805
807
  const target = args[0]
@@ -807,12 +809,13 @@ const installSkillCmd: CommandHandler = async (_ctx, args) => {
807
809
  return { content: 'Usage: /install-skill <skill-name> or /install-skill <url>' }
808
810
  }
809
811
 
812
+ const marketplaceConfig = ctx.config.marketplace
810
813
  let result: { success: boolean; name: string; message: string }
811
814
 
812
815
  if (target.startsWith('http://') || target.startsWith('https://')) {
813
- result = installSkillFromUrl(target)
816
+ result = installSkillFromUrl(target, marketplaceConfig)
814
817
  } else {
815
- result = installSkill(target)
818
+ result = installSkill(target, marketplaceConfig)
816
819
  }
817
820
 
818
821
  return {
@@ -1681,7 +1684,7 @@ function gitDiffBridgeCmd(opts: {
1681
1684
  label: string
1682
1685
  noChangesHint: string
1683
1686
  runningMsg: string
1684
- forwardToAI: string
1687
+ forwardToAI: string | (() => string)
1685
1688
  }): CommandHandler {
1686
1689
  return async () => {
1687
1690
  try {
@@ -1692,7 +1695,7 @@ function gitDiffBridgeCmd(opts: {
1692
1695
  }
1693
1696
  return {
1694
1697
  content: `─ ${opts.label} ─\n\n${opts.runningMsg}\n\nChanged files:\n${diff}`,
1695
- forwardToAI: opts.forwardToAI,
1698
+ forwardToAI: typeof opts.forwardToAI === 'function' ? opts.forwardToAI() : opts.forwardToAI,
1696
1699
  }
1697
1700
  } catch {
1698
1701
  return {
@@ -1708,8 +1711,8 @@ const codeReviewCmd = gitDiffBridgeCmd({
1708
1711
  'No uncommitted changes to review.\n\nTo review a specific file: /code-review path/to/file.ts',
1709
1712
  runningMsg:
1710
1713
  'Reviewing uncommitted changes with the code-review skill (7 dimensions: correctness, security, performance, code quality, architecture, testing, language-specific)...',
1711
- forwardToAI:
1712
- 'use the code-review skill to review all uncommitted changes. Check all 7 dimensions: correctness, security, performance, code quality, architecture & design, testing, and language-specific issues.',
1714
+ forwardToAI: () =>
1715
+ `use the code-review skill to review all uncommitted changes. Check all 7 dimensions: correctness, security, performance, code quality, architecture & design, testing, and language-specific issues. Use effort level: ${getPreference('lastCodeReviewEffort', 'high')}.`,
1713
1716
  })
1714
1717
 
1715
1718
  const simplifyCmd = gitDiffBridgeCmd({
@@ -1887,7 +1890,7 @@ const summaryCmd: CommandHandler = (ctx) => {
1887
1890
  }
1888
1891
  }
1889
1892
 
1890
- const cdCmd: CommandHandler = async (_ctx, args) => {
1893
+ const cdCmd: CommandHandler = async (ctx, args) => {
1891
1894
  const target = args[0]
1892
1895
  if (!target) {
1893
1896
  return {
@@ -1912,6 +1915,22 @@ const cdCmd: CommandHandler = async (_ctx, args) => {
1912
1915
 
1913
1916
  try {
1914
1917
  process.chdir(resolved)
1918
+
1919
+ // Persist cwd to active session (best-effort)
1920
+ try {
1921
+ const { SessionStore } = await import('../core/session-store')
1922
+ const saved = SessionStore.load(ctx.sessionId)
1923
+ if (saved) {
1924
+ SessionStore.save(ctx.sessionId, saved.messages, {
1925
+ provider: saved.metadata.provider,
1926
+ model: saved.metadata.model,
1927
+ cwd: resolved,
1928
+ })
1929
+ }
1930
+ } catch {
1931
+ /* session persistence is best-effort */
1932
+ }
1933
+
1915
1934
  return {
1916
1935
  content: [
1917
1936
  '── Directory Changed ──',
@@ -2039,48 +2058,10 @@ const exportCmd: CommandHandler = async (ctx) => {
2039
2058
  }
2040
2059
 
2041
2060
  // ═══════════════════════════════════════════════════════════════
2042
- // Review — code review workflow
2061
+ // Review — alias for /code-review (P1: 2026-08-06 polish)
2043
2062
  // ═══════════════════════════════════════════════════════════════
2044
2063
 
2045
- const reviewCmd: CommandHandler = async () => {
2046
- try {
2047
- const { execSync } = await import('node:child_process')
2048
- const diff = execSync('git diff --stat', { encoding: 'utf-8', timeout: 5000 }).trim()
2049
- const unstaged = execSync('git diff --name-only', { encoding: 'utf-8', timeout: 3000 }).trim()
2050
- const staged = execSync('git diff --cached --name-only', {
2051
- encoding: 'utf-8',
2052
- timeout: 3000,
2053
- }).trim()
2054
-
2055
- if (!diff) {
2056
- return {
2057
- content:
2058
- '─ Code Review ─\n\nNo uncommitted changes detected.\n\nUse /pr-comments for PR-level review, or make changes first.',
2059
- }
2060
- }
2061
-
2062
- const lines: string[] = ['─ Code Review ─', '', 'Uncommitted changes:', '', diff]
2063
-
2064
- if (staged) {
2065
- lines.push('')
2066
- lines.push('Staged files (ready for commit):')
2067
- for (const f of staged.split('\n')) lines.push(` ✓ ${f}`)
2068
- }
2069
- if (unstaged) {
2070
- lines.push('')
2071
- lines.push('Unstaged files (working directory):')
2072
- for (const f of unstaged.split('\n')) lines.push(` • ${f}`)
2073
- }
2074
-
2075
- lines.push('')
2076
- lines.push('To review with AI: type "review these changes" in chat.')
2077
- lines.push('To commit: git add -A && git commit -m "..."')
2078
-
2079
- return { content: lines.join('\n') }
2080
- } catch {
2081
- return { content: '─ Code Review ─\n\nCould not run git diff. Are you in a git repository?' }
2082
- }
2083
- }
2064
+ const reviewCmd = codeReviewCmd
2084
2065
 
2085
2066
  // ═══════════════════════════════════════════════════════════════
2086
2067
  // PR Comments
@@ -4320,7 +4301,7 @@ const commandsListCmd: CommandHandler = () => {
4320
4301
  '/tdd': 'Workflow',
4321
4302
  '/todos': 'Workflow',
4322
4303
  '/tasks': 'Workflow',
4323
- '/review': 'Workflow',
4304
+ '/review': 'Code Quality',
4324
4305
  '/pr-comments': 'Workflow',
4325
4306
  '/diff': 'Workflow',
4326
4307
  '/workflows': 'Workflow',
@@ -4587,7 +4568,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
4587
4568
  '/tdd': 'Test-Driven Development workflow (RED → GREEN → REFACTOR)',
4588
4569
  '/todos': 'Task management (list/create tasks)',
4589
4570
  '/tasks': 'Background tasks',
4590
- '/review': 'Code review workflow',
4571
+ '/review': 'Review code for bugs, security, performance (alias for /code-review)',
4591
4572
  '/pr-comments': 'PR review summary',
4592
4573
  '/diff': 'Show git diff',
4593
4574
  '/workflows': 'List workflow scripts',
@@ -1,3 +1,4 @@
1
+ import vm from 'node:vm'
1
2
  import { createSandbox } from './sandbox'
2
3
  import { createJournal, appendJournal, loadJournal, loadScript } from './journal'
3
4
  import { createBudget } from './budget'
@@ -47,7 +48,8 @@ function agentCacheKey(prompt: string, opts: Record<string, unknown> = {}): stri
47
48
  * - Returns cacheHits and cacheMisses counts
48
49
  *
49
50
  * The sandbox blocks non-deterministic APIs (Date.now, Math.random) to make
50
- * this deterministic replay possible.
51
+ * this deterministic replay possible. Scripts execute inside a node:vm.Context
52
+ * that blocks sandbox escape vectors (eval, Function, import, require, process, etc.).
51
53
  */
52
54
  export async function runWorkflow(
53
55
  script: string,
@@ -134,42 +136,28 @@ export async function runWorkflow(
134
136
  appendJournal(runId, { type: 'log', message })
135
137
  }
136
138
 
137
- const sandbox = createSandbox(args, budget)
138
-
139
139
  // Build the script wrapper
140
140
  const wrappedScript = `
141
- return (async () => {
141
+ (async () => {
142
142
  ${script}
143
143
  })()
144
144
  `
145
145
 
146
- // Execute in sandboxed context
147
- const scriptFn = new Function(
148
- 'agent',
149
- 'parallel',
150
- 'pipeline',
151
- 'verify',
152
- 'judge',
153
- 'loopUntilConvergence',
154
- 'phase',
155
- 'log',
156
- 'args',
157
- 'budget',
158
- wrappedScript,
159
- )
160
-
161
- const result = await scriptFn(
162
- agent,
163
- parallel,
164
- pipeline,
165
- verify,
166
- judge,
167
- loopUntilConvergence,
168
- wrappedPhase,
169
- log,
170
- args,
171
- budget,
172
- )
146
+ // Execute in node:vm sandbox — no access to host globals
147
+ const vmScript = new vm.Script(wrappedScript, { filename: 'workflow.js' })
148
+ const sandboxCtx = createSandbox(args, budget)
149
+
150
+ // Inject primitives into the sandbox context (whitelist approach)
151
+ sandboxCtx.agent = agent
152
+ sandboxCtx.parallel = parallel
153
+ sandboxCtx.pipeline = pipeline
154
+ sandboxCtx.verify = verify
155
+ sandboxCtx.judge = judge
156
+ sandboxCtx.loopUntilConvergence = loopUntilConvergence
157
+ sandboxCtx.phase = wrappedPhase
158
+ sandboxCtx.log = log
159
+
160
+ const result = await vmScript.runInContext(sandboxCtx, { timeout: 120_000 })
173
161
 
174
162
  // Count journal entries from state
175
163
  const priorEntries = loadJournal(runId)
@@ -1,27 +1,39 @@
1
- /** APIs disabled in workflow scripts to ensure deterministic replay. */
1
+ import vm from 'node:vm'
2
+
3
+ /** APIs disabled in workflow scripts to ensure deterministic replay + sandbox escape prevention. */
2
4
  const FORBIDDEN = new Set(['Date.now', 'Math.random', 'crypto.randomUUID'])
3
5
 
4
6
  /**
5
- * Create a sandboxed global scope for workflow script execution.
6
- * Blocks Date.now(), Math.random(), argless new Date(), crypto.randomUUID().
7
+ * Create a sandboxed VM context for workflow script execution.
8
+ * Blocks Date.now(), Math.random(), argless new Date(), crypto.randomUUID(),
9
+ * plus explicit sandbox escape vectors: eval, Function constructor, import(),
10
+ * require(), process, Bun, fetch, setTimeout/setInterval, etc.
7
11
  */
8
12
  export function createSandbox(
9
13
  args: unknown,
10
14
  budget: { total: number | null; spent(): number; remaining(): number },
11
- ): Record<string, unknown> {
12
- const sandbox: Record<string, unknown> = {
15
+ ): vm.Context {
16
+ const sandboxObj: Record<string, unknown> = {
13
17
  args,
14
18
  budget,
15
19
  console: {
16
20
  log: (..._a: unknown[]) => {}, // no-op in sandbox
17
21
  error: (..._a: unknown[]) => {},
18
22
  },
19
- // Primitives are injected by the runtime, not the sandbox
23
+
24
+ // ── Explicit sandbox escape prevention ──
25
+ // V8 builtins that would otherwise be available in a vm.Context
26
+ eval: () => {
27
+ throw new Error('eval() is disabled in workflow sandbox.')
28
+ },
29
+ Function: () => {
30
+ throw new Error('new Function() is disabled in workflow sandbox.')
31
+ },
20
32
  }
21
33
 
22
34
  // Override Date to block now() and argless constructor
23
35
  const OriginalDate = Date
24
- sandbox.Date = new Proxy(OriginalDate, {
36
+ sandboxObj.Date = new Proxy(OriginalDate, {
25
37
  construct(_target, constructorArgs) {
26
38
  if (constructorArgs.length === 0) {
27
39
  throw new Error('new Date() is disabled in workflow sandbox. Pass timestamps via args.')
@@ -42,7 +54,7 @@ export function createSandbox(
42
54
  })
43
55
 
44
56
  // Override Math.random
45
- sandbox.Math = new Proxy(Math, {
57
+ sandboxObj.Math = new Proxy(Math, {
46
58
  get(_target, prop) {
47
59
  if (prop === 'random') {
48
60
  throw new Error('Math.random() is disabled in workflow sandbox. Use a seed from args.')
@@ -57,7 +69,7 @@ export function createSandbox(
57
69
  | { randomUUID?: unknown; [key: string]: unknown }
58
70
  | undefined
59
71
  if (globalCrypto) {
60
- sandbox.crypto = new Proxy(globalCrypto, {
72
+ sandboxObj.crypto = new Proxy(globalCrypto, {
61
73
  get(_target, prop) {
62
74
  if (prop === 'randomUUID') {
63
75
  throw new Error('crypto.randomUUID() is disabled in workflow sandbox.')
@@ -70,7 +82,7 @@ export function createSandbox(
70
82
  })
71
83
  }
72
84
 
73
- return sandbox
85
+ return vm.createContext(sandboxObj)
74
86
  }
75
87
 
76
88
  /** Check whether a given API identifier is in the forbidden set. */