@miphamai/cli 0.24.16 → 0.25.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.24.16",
3
+ "version": "0.25.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -56,15 +56,20 @@ export class AgentMessageBus {
56
56
  /**
57
57
  * Get all unread messages addressed to the given agent.
58
58
  * Does NOT mark them as read — use markRead() for that.
59
+ * P2-3: Auto-prunes messages older than 1 hour before polling.
59
60
  */
60
61
  poll(agentId: string): AgentMessage[] {
62
+ // Auto-prune stale messages to prevent accumulation and stuck states
63
+ this.prune(60 * 60 * 1000) // 1 hour TTL
61
64
  return this.messages.filter((m) => m.to === agentId && !m.read)
62
65
  }
63
66
 
64
67
  /**
65
68
  * Get all messages addressed to the given agent (read + unread).
69
+ * P2-3: Auto-prunes stale messages before listing.
66
70
  */
67
71
  list(agentId: string): AgentMessage[] {
72
+ this.prune(60 * 60 * 1000) // 1 hour TTL
68
73
  return this.messages.filter((m) => m.to === agentId)
69
74
  }
70
75
 
@@ -198,6 +198,22 @@ export class SubAgent {
198
198
  const agentType = options.type || 'general'
199
199
  const agentDef = options.agentDef
200
200
 
201
+ // ── P0-3: Create isolated PermissionSystem for this sub-agent ──
202
+ // The agent definition's permissionMode is clamped against parent's org
203
+ // restrictions (maxAllowedMode, forbiddenModes). When permissionMode is
204
+ // 'inherit' or not set, the parent's mode is used but still clamped.
205
+ let subPermission = this.permission // default: use parent's
206
+ if (this.permission) {
207
+ const agentPermMode =
208
+ agentDef?.permissionMode && agentDef.permissionMode !== 'inherit'
209
+ ? agentDef.permissionMode
210
+ : 'inherit'
211
+ subPermission = this.permission.createSubAgentPermission(agentPermMode)
212
+ // P0-5: Enable sub-agent safety mode — auto mode returns 'ask' for
213
+ // Bash/Write/Edit so hooks remain the safety gate in background agents.
214
+ subPermission.setSubAgentMode(true)
215
+ }
216
+
201
217
  // Resolve execution directory: worktree isolation or process cwd
202
218
  const execCwd = options.worktreePath || process.cwd()
203
219
 
@@ -212,6 +228,25 @@ export class SubAgent {
212
228
  }
213
229
  // Otherwise, proceed with a warning — don't block agent execution
214
230
  }
231
+
232
+ // P0-4: Auto-disallow Git tool for worktree-isolated sub-agents
233
+ // to prevent destructive operations on the main checkout.
234
+ // Agent definitions can explicitly re-enable Git if needed.
235
+ if (!agentDef?.tools) {
236
+ // No explicit allowlist — add Git to disallowedTools
237
+ const existingDisallowed = agentDef?.disallowedTools
238
+ ? agentDef.disallowedTools
239
+ .split(',')
240
+ .map((s) => s.trim())
241
+ .filter(Boolean)
242
+ : []
243
+ if (!existingDisallowed.includes('Git')) {
244
+ existingDisallowed.push('Git')
245
+ }
246
+ if (agentDef) {
247
+ agentDef.disallowedTools = existingDisallowed.join(',')
248
+ }
249
+ }
215
250
  }
216
251
 
217
252
  // Resolve system prompt: agentDef > options.systemPrompt > builtin type
@@ -352,11 +387,36 @@ export class SubAgent {
352
387
  continue
353
388
  }
354
389
 
390
+ // ── P0-5: Run PreToolUse hooks (before permission check) ──
391
+ let effectiveInput = tu.input
392
+ if (this.hookEngine) {
393
+ const preResult = await this.hookEngine.executePreToolUse(
394
+ tu.name,
395
+ tu.input,
396
+ 'sub-agent',
397
+ )
398
+ if (!preResult.allowed) {
399
+ const denialMsg = preResult.reason
400
+ ? `Tool "${tu.name}" blocked by PreToolUse hook: ${preResult.reason}`
401
+ : `Tool "${tu.name}" blocked by PreToolUse hook.`
402
+ currentMessages.push({
403
+ role: 'user' as const,
404
+ content: denialMsg,
405
+ })
406
+ continue
407
+ }
408
+ // Apply modified input from hooks
409
+ if (preResult.modifiedInput) {
410
+ effectiveInput = { ...tu.input, ...preResult.modifiedInput }
411
+ }
412
+ }
413
+
355
414
  // Security: check permission before executing.
356
415
  // Sub-agents run without user interaction — tools requiring approval are rejected.
357
416
  // When permission system is absent (undefined), allow all tools (backward compat
358
417
  // for tests and headless usage). When present, always enforce approval checks.
359
- if (this.permission?.needsApproval(tool, tu.input)) {
418
+ // P0-3: Uses isolated subPermission (clamped by org restrictions) instead of parent's.
419
+ if (subPermission?.needsApproval(tool, effectiveInput)) {
360
420
  currentMessages.push({
361
421
  role: 'user' as const,
362
422
  content:
@@ -367,16 +427,40 @@ export class SubAgent {
367
427
  }
368
428
 
369
429
  try {
370
- const result = await tool.execute(tu.input, {
430
+ const result = await tool.execute(effectiveInput, {
371
431
  cwd: execCwd,
372
432
  sessionId: 'sub-agent',
373
433
  provider: '',
374
434
  model: finalModel,
375
435
  })
376
436
 
437
+ // ── P0-5: Run PostToolUse hooks ──
438
+ let displayResult = result
439
+ if (this.hookEngine) {
440
+ const postResult = await this.hookEngine.executePostToolUse(
441
+ tu.name,
442
+ effectiveInput,
443
+ result,
444
+ 'sub-agent',
445
+ )
446
+ if (postResult.updatedOutput) {
447
+ displayResult = { ...result, content: postResult.updatedOutput }
448
+ }
449
+ if (postResult.additionalContext) {
450
+ displayResult = {
451
+ ...displayResult,
452
+ content: displayResult.content
453
+ ? displayResult.content + '\n' + postResult.additionalContext
454
+ : postResult.additionalContext,
455
+ }
456
+ }
457
+ }
458
+
377
459
  currentMessages.push({
378
460
  role: 'assistant' as const,
379
- content: [{ type: 'tool_use' as const, id: tu.id, name: tu.name, input: tu.input }],
461
+ content: [
462
+ { type: 'tool_use' as const, id: tu.id, name: tu.name, input: effectiveInput },
463
+ ],
380
464
  })
381
465
  currentMessages.push({
382
466
  role: 'user' as const,
@@ -384,11 +468,22 @@ export class SubAgent {
384
468
  {
385
469
  type: 'tool_result' as const,
386
470
  tool_use_id: tu.id,
387
- content: result.success ? result.content : result.error || result.content,
471
+ content: displayResult.success
472
+ ? displayResult.content
473
+ : displayResult.error || displayResult.content,
388
474
  },
389
475
  ],
390
476
  })
391
477
  } catch (err) {
478
+ // ── P0-5: Run PostToolUseFailure hooks ──
479
+ if (this.hookEngine) {
480
+ this.hookEngine
481
+ .executePostToolUseFailure(tu.name, effectiveInput, String(err), 'sub-agent')
482
+ .catch(() => {
483
+ // Hook failures never block execution
484
+ })
485
+ }
486
+
392
487
  currentMessages.push({
393
488
  role: 'user' as const,
394
489
  content: `Tool "${tu.name}" execution error: ${String(err)}`,
@@ -8,7 +8,8 @@ export interface AgentFrontmatter {
8
8
  tools?: string // comma-separated allowlist
9
9
  disallowedTools?: string
10
10
  model?: string // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
11
- permissionMode?: 'default' | 'acceptEdits' | 'auto' | 'bypass' | 'plan'
11
+ permissionMode?:
12
+ 'default' | 'acceptEdits' | 'auto' | 'bypass' | 'plan' | 'bypassPermissions' | 'dontAsk'
12
13
  maxTurns?: number
13
14
  skills?: string
14
15
  background?: boolean
@@ -112,6 +112,16 @@ export class QueryEngine {
112
112
  if (messages.length > 0) {
113
113
  const bus = getMessageBus()
114
114
  for (const msg of messages) {
115
+ // P2-1: Trigger Notification hook for cross-session messages
116
+ if (this.hookEngine) {
117
+ this.hookEngine
118
+ .executeNotification(
119
+ `Cross-session message from ${msg.from}: ${msg.summary}`,
120
+ this.sessionId,
121
+ )
122
+ .catch(() => {})
123
+ }
124
+
115
125
  if (policy === 'ask') {
116
126
  // Mark as awaiting approval — the model should verify with the user before acting
117
127
  bus.post(
@@ -836,17 +846,31 @@ export class QueryEngine {
836
846
  private async executeTool(name: string, params: Record<string, unknown>): Promise<ToolResult> {
837
847
  const tool = this.tools.get(name)
838
848
  if (!tool) {
839
- return { success: false, content: '', error: `Unknown tool: ${name}` }
849
+ // P2-4: Provide a more helpful error for unavailable tools
850
+ const isMcpTool = name.startsWith('mcp__')
851
+ const hint = isMcpTool
852
+ ? `\nMCP tool "${name}" is no longer available. The MCP server may have been removed or disconnected. Use tool-search to discover available tools.`
853
+ : `\nTool "${name}" is not registered. Available tools may have changed — check the tool list.`
854
+ return { success: false, content: '', error: `Unknown tool: ${name}${hint}` }
840
855
  }
841
856
 
842
857
  // Security: check permission before executing
843
858
  if (this.permission.needsApproval(tool, params)) {
859
+ // P1-4: Increment consecutive block counter; if limit exceeded,
860
+ // tell the model to move on instead of retrying.
861
+ const limitExceeded = this.permission.incrementBlockCounter()
862
+ const baseError = t('errors.tool_not_allowed', { name })
863
+ const moveOnHint = limitExceeded
864
+ ? '\n(Consecutive block limit reached. Please try a different approach or ask the user for guidance.)'
865
+ : ''
844
866
  return {
845
867
  success: false,
846
868
  content: '',
847
- error: t('errors.tool_not_allowed', { name }),
869
+ error: baseError + moveOnHint,
848
870
  }
849
871
  }
872
+ // P1-4: Tool allowed — reset block counter
873
+ this.permission.resetBlockCounter()
850
874
 
851
875
  // Run PreToolUse hooks
852
876
  let effectiveParams = params
@@ -902,7 +926,21 @@ export class QueryEngine {
902
926
 
903
927
  // Run PostToolUse hooks
904
928
  if (this.hookEngine) {
905
- await this.hookEngine.executePostToolUse(name, effectiveParams, result, this.sessionId)
929
+ const postResult = await this.hookEngine.executePostToolUse(
930
+ name,
931
+ effectiveParams,
932
+ result,
933
+ this.sessionId,
934
+ )
935
+ // P1-2: Consume hook result — apply updatedOutput and additionalContext
936
+ if (postResult.updatedOutput) {
937
+ result.content = postResult.updatedOutput
938
+ }
939
+ if (postResult.additionalContext) {
940
+ result.content = result.content
941
+ ? result.content + '\n' + postResult.additionalContext
942
+ : postResult.additionalContext
943
+ }
906
944
  }
907
945
 
908
946
  // CRSI: Track rule effectiveness after tool execution
@@ -920,6 +958,15 @@ export class QueryEngine {
920
958
 
921
959
  return result
922
960
  } catch (err) {
961
+ // P1-3: Trigger PostToolUseFailure hook on tool execution errors
962
+ if (this.hookEngine) {
963
+ this.hookEngine
964
+ .executePostToolUseFailure(name, effectiveParams, String(err), this.sessionId)
965
+ .catch(() => {
966
+ // Hook failures never block error handling
967
+ })
968
+ }
969
+
923
970
  // Sanitize error: strip stack traces and internal paths to prevent
924
971
  // information disclosure to the LLM conversation context.
925
972
  const message =
@@ -47,7 +47,7 @@ export class InstructionsLoader {
47
47
  this.tryLoad(join(home, '.mipham', 'USER.md'), 'user')
48
48
  }
49
49
 
50
- buildSystemPrompt(): string {
50
+ buildSystemPrompt(permissionMode?: string): string {
51
51
  const parts: string[] = []
52
52
 
53
53
  for (const inst of this.instructions) {
@@ -61,6 +61,11 @@ export class InstructionsLoader {
61
61
  parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${inst.content}`)
62
62
  }
63
63
 
64
+ // P2-2: Inject current permission mode so the model knows its constraints
65
+ if (permissionMode) {
66
+ parts.push(this.buildPermissionContext(permissionMode))
67
+ }
68
+
64
69
  // Append skills reminder after all instructions
65
70
  if (this.skillsReminder) {
66
71
  parts.push(this.skillsReminder)
@@ -102,6 +107,30 @@ Script format: export const meta = { name, description, phases: [...] }
102
107
  this.skillsReminder = reminder
103
108
  }
104
109
 
110
+ /**
111
+ * P2-2: Build a permission-mode context block for the system prompt.
112
+ * Tells the model its current permission level and what to expect.
113
+ */
114
+ private buildPermissionContext(mode: string): string {
115
+ const modeDescriptions: Record<string, string> = {
116
+ default:
117
+ 'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
118
+ acceptEdits:
119
+ 'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
120
+ plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
121
+ auto: 'You are in **auto** mode. Most tools run without approval. If a tool is blocked by security policy or hooks, try a different approach instead of retrying.',
122
+ dontAsk:
123
+ 'You are in **dontAsk** mode. All tools blocked unless explicitly allowlisted. Check your allow rules before acting.',
124
+ bypassPermissions:
125
+ 'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
126
+ }
127
+
128
+ const description = modeDescriptions[mode]
129
+ if (!description) return ''
130
+
131
+ return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry with the same tool and similar parameters. Move on to a different approach or ask the user for guidance.`
132
+ }
133
+
105
134
  list(): InstructionFile[] {
106
135
  return [...this.instructions]
107
136
  }
@@ -29,6 +29,13 @@ export class PermissionSystem {
29
29
  // ── Org-level restrictions (P0 security) ──
30
30
  private restrictions: PermissionRestrictions | undefined = undefined
31
31
 
32
+ // ── P0-5: Sub-agent mode (auto mode hardening for background tasks) ──
33
+ private isSubAgent = false
34
+
35
+ // ── P1-4: Consecutive block counter (prevents infinite retry loops) ──
36
+ private consecutiveBlockCount = 0
37
+ private static readonly MAX_CONSECUTIVE_BLOCKS = 3
38
+
32
39
  /** Invalidate the permission cache (called on any rule/mode change). */
33
40
  private invalidateCache(): void {
34
41
  this.checkCache.clear()
@@ -77,6 +84,85 @@ export class PermissionSystem {
77
84
  return this.restrictions
78
85
  }
79
86
 
87
+ /**
88
+ * P0-5 (v2.1.222 alignment): Enable sub-agent safety mode.
89
+ * When true, auto mode returns 'ask' for Bash/Write/Edit tools instead of
90
+ * 'bypass', ensuring PreToolUse hooks remain the safety gate in background agents.
91
+ */
92
+ setSubAgentMode(enabled: boolean): void {
93
+ this.isSubAgent = enabled
94
+ this.invalidateCache()
95
+ }
96
+
97
+ /**
98
+ * P1-4 (v2.1.225 alignment): Increment the consecutive block counter
99
+ * when a tool is denied. Safety-filter refusals should NOT count.
100
+ * Returns true if the consecutive block limit has been exceeded.
101
+ */
102
+ incrementBlockCounter(): boolean {
103
+ this.consecutiveBlockCount++
104
+ return this.consecutiveBlockCount >= PermissionSystem.MAX_CONSECUTIVE_BLOCKS
105
+ }
106
+
107
+ /** Reset the block counter when a tool is successfully executed. */
108
+ resetBlockCounter(): void {
109
+ this.consecutiveBlockCount = 0
110
+ }
111
+
112
+ /** Get the current consecutive block count. */
113
+ getBlockCount(): number {
114
+ return this.consecutiveBlockCount
115
+ }
116
+
117
+ /**
118
+ * P0-3 (v2.1.223 alignment): Create a permission context for a sub-agent.
119
+ *
120
+ * The sub-agent's requested permissionMode is clamped against the parent's
121
+ * org restrictions (maxAllowedMode, forbiddenModes). Deny rules from the
122
+ * parent are propagated so org safety policies always apply.
123
+ *
124
+ * Returns a new PermissionSystem instance — NOT shared with the parent.
125
+ */
126
+ createSubAgentPermission(agentPermissionMode: string): PermissionSystem {
127
+ const resolvedMode = this.resolveAgentMode(agentPermissionMode)
128
+ const subPerm = new PermissionSystem(resolvedMode)
129
+
130
+ // Propagate org restrictions to sub-agent
131
+ if (this.restrictions) {
132
+ subPerm.setRestrictions(this.restrictions)
133
+ }
134
+
135
+ // Propagate deny rules (org safety policies must always apply)
136
+ for (const denyEntry of this.denyRules) {
137
+ subPerm.deny(denyEntry.pattern)
138
+ }
139
+
140
+ return subPerm
141
+ }
142
+
143
+ /**
144
+ * Resolve an agent's permissionMode string to a clamped PermissionMode.
145
+ * 'inherit' means "use the parent's current mode".
146
+ * 'bypass' is treated as an alias for 'bypassPermissions'.
147
+ */
148
+ private resolveAgentMode(agentMode: string): PermissionMode {
149
+ // Normalize aliases
150
+ const normalized = agentMode === 'bypass' ? 'bypassPermissions' : agentMode
151
+
152
+ const modeMap: Record<string, PermissionMode> = {
153
+ bypassPermissions: 'bypassPermissions',
154
+ dontAsk: 'dontAsk',
155
+ auto: 'auto',
156
+ plan: 'plan',
157
+ acceptEdits: 'acceptEdits',
158
+ default: 'default',
159
+ inherit: this.mode,
160
+ }
161
+
162
+ const desired: PermissionMode = modeMap[normalized] || 'default'
163
+ return clampMode(desired, this.restrictions)
164
+ }
165
+
80
166
  // ── Rule management ──
81
167
 
82
168
  allow(rule: string): void {
@@ -267,6 +353,12 @@ export class PermissionSystem {
267
353
  if (tool.name === 'SendMessage') {
268
354
  return 'mode-baseline'
269
355
  }
356
+ // P0-5: In sub-agent context, enforce 'ask' for destructive tools
357
+ // so hooks remain the sole safety gate. Without hooks, these tools
358
+ // would otherwise run completely un-gated in auto mode.
359
+ if (this.isSubAgent && ['Bash', 'Write', 'Edit'].includes(tool.name)) {
360
+ return 'ask'
361
+ }
270
362
  return 'bypass'
271
363
 
272
364
  case 'dontAsk':
package/src/index.tsx CHANGED
@@ -238,12 +238,12 @@ export async function runApp(options: RunOptions): Promise<void> {
238
238
  for (const msg of saved.messages) {
239
239
  context.addMessage(msg)
240
240
  }
241
- context.setSystemPrompt(instructions.buildSystemPrompt())
241
+ context.setSystemPrompt(instructions.buildSystemPrompt(config.permission as string))
242
242
  }
243
243
  }
244
244
 
245
245
  if (context.getMessageCount() === 0) {
246
- const basePrompt = instructions.buildSystemPrompt()
246
+ const basePrompt = instructions.buildSystemPrompt(config.permission as string)
247
247
  const memoryReminder = loadSessionMemories(basePrompt)
248
248
 
249
249
  // Inject previous session summary for AI continuity
@@ -430,12 +430,19 @@ export async function runApp(options: RunOptions): Promise<void> {
430
430
  const crossSessionConfig = loadCrossSessionConfig(process.cwd())
431
431
  engine.setCrossSessionConfig(crossSessionConfig)
432
432
 
433
+ // P2-1: Trigger SessionStart hooks after full initialization
434
+ hookEngine.executeSessionStart(sessionName).catch(() => {
435
+ // Hook failures never block session startup
436
+ })
437
+
433
438
  // Auto-save session on exit
434
439
  let saved = false
435
440
  const saveAndExit = () => {
436
441
  saved = true
437
442
  clearInterval(heartbeatInterval)
438
443
  unregisterSession(sessionName)
444
+ // P2-1: Trigger SessionEnd hooks before cleanup (best-effort)
445
+ hookEngine.executeSessionEnd(sessionName).catch(() => {})
439
446
  artifactServer.stop()
440
447
  if (context.getMessageCount() > 0) {
441
448
  SessionStore.save(sessionName, context.getMessages(), {
@@ -4,6 +4,10 @@
4
4
  * from visual inspection or enable homoglyph attacks.
5
5
  *
6
6
  * Protects against hidden command text via tabs/invisible Unicode characters.
7
+ *
8
+ * P0-1 (v2.1.223 alignment): Extended to also strip tab, CR, VT, FF,
9
+ * other control characters, and normalize fullwidth/homoglyph characters
10
+ * to ASCII equivalents for permission checks.
7
11
  */
8
12
 
9
13
  const DANGEROUS_UNICODE = /[​‌‍‎‏‪‫‬‭‮⁠⁦⁧⁨⁩]/g
@@ -36,3 +40,181 @@ export function sanitizeParams(params: Record<string, unknown>): Record<string,
36
40
  }
37
41
  return result
38
42
  }
43
+
44
+ // ── P0-1: Extended sanitization for permission checks ──
45
+
46
+ // Fullwidth Latin letters (U+FF21-FF3A → A-Z, U+FF41-FF5A → a-z)
47
+ const FULLWIDTH_UPPER_START = 0xff21 // A
48
+ const FULLWIDTH_LOWER_START = 0xff41 // a
49
+ const ASCII_UPPER_START = 0x41 // A
50
+ const ASCII_LOWER_START = 0x61 // a
51
+
52
+ // Fullwidth digits (U+FF10-FF19 → 0-9)
53
+ const FULLWIDTH_DIGIT_START = 0xff10 // 0
54
+ const ASCII_DIGIT_START = 0x30 // 0
55
+
56
+ /** Map of fullwidth/homoglyph punctuation to ASCII equivalents */
57
+ const FULLWIDTH_PUNCTUATION: Record<number, number> = {
58
+ 0xff0f: 0x2f, // / → /
59
+ 0xff0d: 0x2d, // - → -
60
+ 0xff04: 0x24, // $ → $
61
+ 0xff08: 0x28, // ( → (
62
+ 0xff09: 0x29, // ) → )
63
+ 0xff01: 0x21, // ! → !
64
+ 0xff20: 0x40, // @ → @
65
+ 0xff03: 0x23, // # → #
66
+ 0xff1a: 0x3a, // : → :
67
+ 0xff1b: 0x3b, // ; → ;
68
+ 0xff1c: 0x3c, // < → <
69
+ 0xff1e: 0x3e, // > → >
70
+ 0xff3b: 0x5b, // [ → [
71
+ 0xff3d: 0x5d, // ] → ]
72
+ 0xff3e: 0x5e, // ^ → ^
73
+ 0xff40: 0x60, // ` → `
74
+ 0xff5c: 0x7c, // | → |
75
+ 0xff5e: 0x7e, // ~ → ~
76
+ 0xff07: 0x27, // ' → '
77
+ 0xff02: 0x22, // " → "
78
+ 0xff06: 0x26, // & → &
79
+ 0xff0a: 0x2a, // * → *
80
+ 0xff0b: 0x2b, // + → +
81
+ 0xff0c: 0x2c, // , → ,
82
+ 0xff0e: 0x2e, // . → .
83
+ 0xff1d: 0x3d, // = → =
84
+ 0xff05: 0x25, // % → %
85
+ 0xff3f: 0x5f, // _ → _
86
+ 0xff5b: 0x7b, // { → {
87
+ 0xff5d: 0x7d, // } → }
88
+ 0xff3c: 0x5c, // \ → \
89
+ }
90
+
91
+ /** Common Unicode homoglyphs to ASCII (NOT fullwidth — separate Unicode blocks) */
92
+ const HOMOGLYPH_MAP: Record<number, number> = {
93
+ // Common bypass homoglyphs
94
+ 0x0430: 0x61, // Cyrillic а → Latin a
95
+ 0x0435: 0x65, // Cyrillic е → Latin e
96
+ 0x043e: 0x6f, // Cyrillic о → Latin o
97
+ 0x0440: 0x70, // Cyrillic р → Latin p
98
+ 0x0441: 0x63, // Cyrillic с → Latin c
99
+ 0x0443: 0x79, // Cyrillic у → Latin y
100
+ 0x0445: 0x78, // Cyrillic х → Latin x
101
+ 0x0456: 0x69, // Cyrillic і → Latin i
102
+ 0x03bf: 0x6f, // Greek ο → Latin o
103
+ 0x03c1: 0x70, // Greek ρ → Latin p
104
+ 0x03bd: 0x76, // Greek ν → Latin v (looks like v)
105
+ }
106
+
107
+ /**
108
+ * Normalize fullwidth Latin letters, digits, and punctuation to ASCII equivalents.
109
+ * Also handles common homoglyph substitutions used in bypass attacks.
110
+ *
111
+ * Only normalizes characters in Latin-script ranges — does NOT affect
112
+ * CJK ideographs, Arabic, Devanagari, or other scripts.
113
+ */
114
+ export function normalizeFullwidthAndHomoglyphs(input: string): string {
115
+ if (!input) return input
116
+ let result = ''
117
+ for (let i = 0; i < input.length; i++) {
118
+ const cp = input.codePointAt(i)
119
+ if (cp === undefined) {
120
+ result += input[i]
121
+ continue
122
+ }
123
+
124
+ // Fullwidth upper: A-Z → A-Z
125
+ if (cp >= FULLWIDTH_UPPER_START && cp <= FULLWIDTH_UPPER_START + 25) {
126
+ result += String.fromCodePoint(ASCII_UPPER_START + (cp - FULLWIDTH_UPPER_START))
127
+ }
128
+ // Fullwidth lower: a-z → a-z
129
+ else if (cp >= FULLWIDTH_LOWER_START && cp <= FULLWIDTH_LOWER_START + 25) {
130
+ result += String.fromCodePoint(ASCII_LOWER_START + (cp - FULLWIDTH_LOWER_START))
131
+ }
132
+ // Fullwidth digits: 0-9 → 0-9
133
+ else if (cp >= FULLWIDTH_DIGIT_START && cp <= FULLWIDTH_DIGIT_START + 9) {
134
+ result += String.fromCodePoint(ASCII_DIGIT_START + (cp - FULLWIDTH_DIGIT_START))
135
+ }
136
+ // Fullwidth punctuation
137
+ else if (FULLWIDTH_PUNCTUATION[cp] !== undefined) {
138
+ result += String.fromCodePoint(FULLWIDTH_PUNCTUATION[cp])
139
+ }
140
+ // Homoglyphs
141
+ else if (HOMOGLYPH_MAP[cp] !== undefined) {
142
+ result += String.fromCodePoint(HOMOGLYPH_MAP[cp])
143
+ }
144
+ // Surrogate pairs (code points > U+FFFF) — skip the low surrogate
145
+ else if (cp > 0xffff) {
146
+ result += input[i]
147
+ i++ // skip next char (low surrogate)
148
+ }
149
+ // Keep everything else as-is
150
+ else {
151
+ result += input[i]
152
+ }
153
+ }
154
+ return result
155
+ }
156
+
157
+ // Regex for control characters that should be stripped from permission-check input.
158
+ // Strips: tab (\t), CR (\r), VT (\v), FF (\f), and other control chars
159
+ // in ranges 0x01-0x08 and 0x0E-0x1F.
160
+ // Preserves: newline (\n, 0x0A) — necessary for multi-line shell commands.
161
+ const CONTROL_CHARS_FOR_PERMISSION_CHECK = /[\x00-\x09\x0b\x0c\x0d-\x1f]/g
162
+
163
+ /**
164
+ * Strip control characters from a command string for permission checking.
165
+ * - Tab (\t) → single space (so words don't merge confusingly)
166
+ * - CR (\r), VT (\v), FF (\f) → removed
167
+ * - Other control chars (0x01-0x08, 0x0E-0x1F) → removed
168
+ * - Newline (\n) is PRESERVED for multi-line commands
169
+ */
170
+ export function stripControlCharsForCheck(input: string): string {
171
+ if (!input) return input
172
+ // First, replace tabs with spaces
173
+ let result = input.replace(/\t/g, ' ')
174
+ // Then strip other control chars (but preserve \n)
175
+ result = result.replace(CONTROL_CHARS_FOR_PERMISSION_CHECK, '')
176
+ return result
177
+ }
178
+
179
+ /**
180
+ * Full command sanitization pipeline for permission checks.
181
+ * Applies in order:
182
+ * 1. Strip dangerous invisible Unicode (zero-width, bidi, BOM, etc.)
183
+ * 2. Strip control characters (tab→space, CR/VT/FF→removed)
184
+ * 3. Normalize fullwidth/homoglyph characters to ASCII
185
+ * 4. Collapse multiple spaces
186
+ * 5. Trim
187
+ *
188
+ * This is intended for pattern-matching input only — the original
189
+ * command string should still be used for actual execution.
190
+ */
191
+ export function sanitizeCommand(input: string): string {
192
+ if (!input) return input
193
+ let result = input
194
+ result = stripDangerousUnicode(result)
195
+ result = stripControlCharsForCheck(result)
196
+ result = normalizeFullwidthAndHomoglyphs(result)
197
+ // Collapse multiple spaces (but preserve newlines)
198
+ result = result.replace(/ {2,}/g, ' ')
199
+ result = result.trim()
200
+ return result
201
+ }
202
+
203
+ /**
204
+ * Sanitize a command string for UI display.
205
+ * Makes invisible/hidden characters visible so users can see
206
+ * what's actually in the command.
207
+ *
208
+ * - Tab → `→` (visible arrow)
209
+ * - CR → `↵` (carriage return symbol)
210
+ * - Zero-width spaces → `⟨ZWSP⟩`
211
+ */
212
+ export function sanitizeForDisplay(input: string): string {
213
+ if (!input) return input
214
+ let result = stripDangerousUnicode(input)
215
+ result = result.replace(/\t/g, '→')
216
+ result = result.replace(/\r/g, '↵')
217
+ result = result.replace(/\v/g, '⟨VT⟩')
218
+ result = result.replace(/\f/g, '⟨FF⟩')
219
+ return result
220
+ }
@@ -33,13 +33,16 @@ export const sendMessageTool: ToolDefinition = {
33
33
  const summary = (params.summary as string) || '(no subject)'
34
34
  const message = params.message as string
35
35
 
36
+ // P1-1: Truncate long summaries instead of rejecting (max 200 chars)
37
+ const truncatedSummary = summary.length > 200 ? summary.slice(0, 197) + '...' : summary
38
+
36
39
  const from =
37
40
  ctx.sessionId === 'sub-agent'
38
41
  ? `sub-agent-${Date.now().toString(36)}`
39
42
  : ctx.sessionId || 'main'
40
43
 
41
44
  const router = getMessageRouter()
42
- const result = await router.route(from, to, summary, message)
45
+ const result = await router.route(from, to, truncatedSummary, message)
43
46
 
44
47
  if (!result.success) {
45
48
  return {
@@ -58,7 +61,7 @@ export const sendMessageTool: ToolDefinition = {
58
61
  `ID: ${result.messageId}\n` +
59
62
  `From: ${from}\n` +
60
63
  `To: ${to}\n` +
61
- `Summary: ${summary.slice(0, 100)}`,
64
+ `Summary: ${truncatedSummary.slice(0, 100)}`,
62
65
  }
63
66
  },
64
67
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
2
+ import { sanitizeCommand } from '../../shared/sanitize.ts'
2
3
 
3
4
  // Injected at startup — set via index.tsx
4
5
  let credentialConfig: CredentialMaskingConfig | undefined
@@ -105,16 +106,27 @@ function normalizeEscapes(command: string): string {
105
106
  function isBlocked(command: string): string | null {
106
107
  // Normalize ANSI-C escape sequences for defense-in-depth
107
108
  const normalized = normalizeEscapes(command)
109
+ // P0-1: Also sanitize for permission check (strips invisible chars, normalizes homoglyphs)
110
+ const sanitized = sanitizeCommand(command)
108
111
 
109
- // Check exact blocked commands (on normalized command)
112
+ // Check exact blocked commands (on normalized + sanitized)
110
113
  const firstWord = normalized.trim().split(/\s+/)[0]
111
114
  if (firstWord && BLOCKED_COMMANDS.includes(firstWord)) {
112
115
  return `Command "${firstWord}" rejected by security policy.`
113
116
  }
117
+ // Also check sanitized first word (catches fullwidth command names after normalization)
118
+ const sanitizedFirstWord = sanitized.trim().split(/\s+/)[0]
119
+ if (
120
+ sanitizedFirstWord &&
121
+ sanitizedFirstWord !== firstWord &&
122
+ BLOCKED_COMMANDS.includes(sanitizedFirstWord)
123
+ ) {
124
+ return `Command "${sanitizedFirstWord}" rejected by security policy.`
125
+ }
114
126
 
115
- // Check dangerous patterns (on both original and normalized)
127
+ // Check dangerous patterns (on original, normalized, and sanitized)
116
128
  for (const pattern of BLOCKED_PATTERNS) {
117
- if (pattern.test(command) || pattern.test(normalized)) {
129
+ if (pattern.test(command) || pattern.test(normalized) || pattern.test(sanitized)) {
118
130
  return `Command rejected by security policy. Pattern matched: ${pattern.source.slice(0, 40)}...`
119
131
  }
120
132
  }
@@ -198,6 +210,31 @@ export const bashTool: ToolDefinition = {
198
210
  const command = params.command as string
199
211
  const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
200
212
 
213
+ // P0-4: Worktree isolation — block cd escape attempts
214
+ const WORKTREE_MARKER = '.claude/worktrees/'
215
+ if (ctx.cwd.includes(WORKTREE_MARKER)) {
216
+ const worktreeRoot = ctx.cwd.substring(0, ctx.cwd.indexOf(WORKTREE_MARKER))
217
+ // Detect cd to absolute paths outside the worktree
218
+ const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
219
+ const cdMatch = command.match(cdEscapePattern)
220
+ if (cdMatch) {
221
+ const target = cdMatch[1] || cdMatch[2] || cdMatch[3] || ''
222
+ // Resolve relative to cwd
223
+ const resolved = target.startsWith('/')
224
+ ? target
225
+ : `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
226
+ if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeRoot + '/')) {
227
+ return {
228
+ success: false,
229
+ content: '',
230
+ error:
231
+ `Worktree isolation: cannot cd outside worktree directory. ` +
232
+ `Attempted: ${target}. Use tools within the worktree only.`,
233
+ }
234
+ }
235
+ }
236
+ }
237
+
201
238
  // Security: check command against deny list
202
239
  const blockedReason = isBlocked(command)
203
240
  if (blockedReason) {
@@ -1,6 +1,62 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
2
 
3
- const DANGEROUS_COMMANDS = ['push --force', 'reset --hard', 'clean -fd', 'branch -D']
3
+ // P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
4
+ // fragile substring matching. Each pattern describes what it blocks.
5
+ const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
6
+ // Destructive push
7
+ { pattern: /\bpush\s+.*--force(?:-with-lease)?\b/, description: 'push --force' },
8
+ { pattern: /\bpush\s+.*-[fF]\b/, description: 'push -f (force)' },
9
+ { pattern: /\bpush\s+--delete\b/, description: 'push --delete (remote branch deletion)' },
10
+ { pattern: /\bpush\s+--mirror\b/, description: 'push --mirror' },
11
+ // Destructive reset
12
+ { pattern: /\breset\s+--hard\b/, description: 'reset --hard' },
13
+ { pattern: /\breset\s+--merge\b/, description: 'reset --merge' },
14
+ { pattern: /\breset\s+--keep\b/, description: 'reset --keep' },
15
+ // Destructive clean
16
+ { pattern: /\bclean\s+-[a-z]*f[a-z]*d[a-z]*\b/, description: 'clean -fd' },
17
+ { pattern: /\bclean\s+-[a-z]*d[a-z]*f[a-z]*\b/, description: 'clean -fd' },
18
+ { pattern: /\bclean\s+-[a-z]*x[a-z]*\b/, description: 'clean -x (remove ignored files)' },
19
+ // Force-delete branch
20
+ { pattern: /\bbranch\s+-D\b/, description: 'branch -D (force delete)' },
21
+ { pattern: /\bbranch\s+--delete\s+--force\b/, description: 'branch --delete --force' },
22
+ // Rebase (potentially destructive)
23
+ { pattern: /\brebase\s+--onto\b/, description: 'rebase --onto' },
24
+ // Force checkout (overwrites local changes)
25
+ { pattern: /\bcheckout\s+.*(?:--force|-f)\b/, description: 'checkout --force' },
26
+ // Stash manipulation
27
+ { pattern: /\bstash\s+drop\b/, description: 'stash drop' },
28
+ { pattern: /\bstash\s+clear\b/, description: 'stash clear' },
29
+ // Identity spoofing via config
30
+ { pattern: /\bconfig\s+.*user\./, description: 'config user.* (identity spoofing)' },
31
+ ]
32
+
33
+ /**
34
+ * Check if the command references paths outside the current working directory
35
+ * when operating in a worktree context.
36
+ */
37
+ function isOutsideWorktree(command: string, cwd: string): string | null {
38
+ const WORKTREE_MARKER = '.claude/worktrees/'
39
+ if (!cwd.includes(WORKTREE_MARKER)) return null
40
+
41
+ // Extract the project root (everything before .claude/worktrees/)
42
+ const worktreeRoot = cwd.substring(0, cwd.indexOf(WORKTREE_MARKER))
43
+
44
+ // Detect git commands that reference the main checkout path
45
+ const mainCheckoutPaths = [/\b--work-tree=([^\s]+)/g, /\b--git-dir=([^\s]+)/g, /\b-C\s+([^\s]+)/g]
46
+
47
+ for (const pathPattern of mainCheckoutPaths) {
48
+ let match: RegExpExecArray | null
49
+ while ((match = pathPattern.exec(command)) !== null) {
50
+ const refPath = match[1]!
51
+ // If the referenced path is outside the worktree, block it
52
+ if (!refPath.startsWith(cwd) && !refPath.startsWith(worktreeRoot + '/')) {
53
+ return `Git command references path outside worktree: ${refPath}`
54
+ }
55
+ }
56
+ }
57
+
58
+ return null
59
+ }
4
60
 
5
61
  export const gitTool: ToolDefinition = {
6
62
  name: 'Git',
@@ -20,16 +76,27 @@ export const gitTool: ToolDefinition = {
20
76
  async execute(params, ctx) {
21
77
  const command = params.command as string
22
78
 
23
- for (const dangerous of DANGEROUS_COMMANDS) {
24
- if (command.includes(dangerous)) {
79
+ // P0-4: Regex-based word-boundary pattern matching
80
+ for (const { pattern, description } of DANGEROUS_GIT_PATTERNS) {
81
+ if (pattern.test(command)) {
25
82
  return {
26
83
  success: false,
27
84
  content: '',
28
- error: `Dangerous git command blocked: "${dangerous}". Run manually if intended.`,
85
+ error: `Dangerous git command blocked: "${description}". Run manually if intended.`,
29
86
  }
30
87
  }
31
88
  }
32
89
 
90
+ // P0-4: Worktree isolation — block commands referencing outside paths
91
+ const worktreeErr = isOutsideWorktree(command, ctx.cwd)
92
+ if (worktreeErr) {
93
+ return {
94
+ success: false,
95
+ content: '',
96
+ error: `Worktree isolation: ${worktreeErr}. Blocked for safety.`,
97
+ }
98
+ }
99
+
33
100
  try {
34
101
  const proc = Bun.spawn(['git', ...command.split(/\s+/)], {
35
102
  cwd: ctx.cwd,
package/src/ui/app.tsx CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  } from './commands'
25
25
  import { useI18n } from '../i18n-context'
26
26
  import type { PermissionMode } from '../shared/index.ts'
27
+ import { sanitizeForDisplay } from '../shared/sanitize.ts'
27
28
 
28
29
  interface AppProps {
29
30
  engine: QueryEngine
@@ -90,7 +91,7 @@ const PERMISSION_COLORS: Record<PermissionMode, string> = {
90
91
  function formatToolDetail(name: string, input: Record<string, unknown>): string {
91
92
  switch (name) {
92
93
  case 'Bash':
93
- return (input.command as string) || ''
94
+ return sanitizeForDisplay((input.command as string) || '')
94
95
  case 'Read':
95
96
  return (input.file_path as string) || ''
96
97
  case 'Write':
@@ -689,10 +690,14 @@ export function App({
689
690
  ⏵⏵ {PERMISSION_LABELS[permissionMode]}
690
691
  </Text>
691
692
  <Text dimColor>
692
- {' '}({t('ui.status.shift_tab_cycle')}: {PERMISSION_LABELS.default} ·{' '}
693
+ {' '}
694
+ ({t('ui.status.shift_tab_cycle')}: {PERMISSION_LABELS.default} ·{' '}
693
695
  {PERMISSION_LABELS.acceptEdits} · {PERMISSION_LABELS.bypassPermissions} ·{' '}
694
696
  {PERMISSION_LABELS.plan} · {PERMISSION_LABELS.auto} · {PERMISSION_LABELS.dontAsk})
695
- {' · '}{t('ui.status.esc_to_interrupt')}{' · '}{t('ui.status.left_for_agents')}
697
+ {' · '}
698
+ {t('ui.status.esc_to_interrupt')}
699
+ {' · '}
700
+ {t('ui.status.left_for_agents')}
696
701
  </Text>
697
702
  </Box>
698
703
  </Box>
@@ -1,8 +1,61 @@
1
1
  /**
2
2
  * parallel() — barrier: executes all thunks concurrently, waits for all.
3
3
  * Failed thunks resolve to null. Never throws.
4
+ *
5
+ * P0-2 (v2.1.223 alignment): Added concurrency cap (MAX_CONCURRENT = 16)
6
+ * to prevent resource exhaustion from unbounded fan-out.
4
7
  */
8
+ const MAX_CONCURRENT = 16
9
+
10
+ /**
11
+ * Simple async semaphore for concurrency limiting.
12
+ */
13
+ class Semaphore {
14
+ private permits: number
15
+ private queue: Array<() => void> = []
16
+
17
+ constructor(count: number) {
18
+ this.permits = count
19
+ }
20
+
21
+ async acquire(): Promise<void> {
22
+ if (this.permits > 0) {
23
+ this.permits--
24
+ return
25
+ }
26
+ return new Promise<void>((resolve) => {
27
+ this.queue.push(resolve)
28
+ })
29
+ }
30
+
31
+ release(): void {
32
+ if (this.queue.length > 0) {
33
+ const next = this.queue.shift()!
34
+ next()
35
+ } else {
36
+ this.permits++
37
+ }
38
+ }
39
+ }
40
+
5
41
  export async function parallel<T>(thunks: Array<() => Promise<T>>): Promise<(T | null)[]> {
6
- const results = await Promise.allSettled(thunks.map((t) => t()))
7
- return results.map((r) => (r.status === 'fulfilled' ? r.value : null))
42
+ if (thunks.length === 0) return []
43
+
44
+ const semaphore = new Semaphore(MAX_CONCURRENT)
45
+ const results: (T | null)[] = new Array(thunks.length)
46
+
47
+ await Promise.all(
48
+ thunks.map(async (thunk, index) => {
49
+ await semaphore.acquire()
50
+ try {
51
+ results[index] = await thunk()
52
+ } catch {
53
+ results[index] = null
54
+ } finally {
55
+ semaphore.release()
56
+ }
57
+ }),
58
+ )
59
+
60
+ return results
8
61
  }
@@ -2,23 +2,72 @@
2
2
  * pipeline() — no barrier: each item flows through all stages independently.
3
3
  * Item A can be in stage 3 while item B is still in stage 1.
4
4
  * Failed items become null and skip remaining stages.
5
+ *
6
+ * P0-2 (v2.1.223 alignment): Added concurrency cap (MAX_CONCURRENT = 16)
7
+ * to prevent resource exhaustion from unbounded fan-out.
5
8
  */
9
+ const MAX_CONCURRENT = 16
10
+
11
+ /**
12
+ * Simple async semaphore for concurrency limiting.
13
+ */
14
+ class Semaphore {
15
+ private permits: number
16
+ private queue: Array<() => void> = []
17
+
18
+ constructor(count: number) {
19
+ this.permits = count
20
+ }
21
+
22
+ async acquire(): Promise<void> {
23
+ if (this.permits > 0) {
24
+ this.permits--
25
+ return
26
+ }
27
+ return new Promise<void>((resolve) => {
28
+ this.queue.push(resolve)
29
+ })
30
+ }
31
+
32
+ release(): void {
33
+ if (this.queue.length > 0) {
34
+ const next = this.queue.shift()!
35
+ next()
36
+ } else {
37
+ this.permits++
38
+ }
39
+ }
40
+ }
41
+
6
42
  export async function pipeline<T, R>(
7
43
  items: T[],
8
44
  ...stages: Array<(item: T, index: number, original: T) => Promise<R>>
9
45
  ): Promise<(R | null)[]> {
10
- // Process each item through all stages concurrently
11
- return Promise.all(
46
+ if (items.length === 0) return []
47
+
48
+ const semaphore = new Semaphore(MAX_CONCURRENT)
49
+ const results: (R | null)[] = new Array(items.length)
50
+
51
+ // Process each item through all stages concurrently, with concurrency cap
52
+ await Promise.all(
12
53
  items.map(async (item, index) => {
13
- let current: unknown = item
14
- for (const stage of stages) {
15
- try {
16
- current = await stage(current as T, index, item)
17
- } catch {
18
- return null // item failed, skip remaining stages
54
+ await semaphore.acquire()
55
+ try {
56
+ let current: unknown = item
57
+ for (const stage of stages) {
58
+ try {
59
+ current = await stage(current as T, index, item)
60
+ } catch {
61
+ results[index] = null
62
+ return // item failed, skip remaining stages
63
+ }
19
64
  }
65
+ results[index] = current as R
66
+ } finally {
67
+ semaphore.release()
20
68
  }
21
- return current as R
22
69
  }),
23
70
  )
71
+
72
+ return results
24
73
  }
@@ -8,6 +8,10 @@ const FORBIDDEN = new Set(['Date.now', 'Math.random', 'crypto.randomUUID'])
8
8
  * Blocks Date.now(), Math.random(), argless new Date(), crypto.randomUUID(),
9
9
  * plus explicit sandbox escape vectors: eval, Function constructor, import(),
10
10
  * require(), process, Bun, fetch, setTimeout/setInterval, etc.
11
+ *
12
+ * P0-2 (v2.1.223 alignment): Added explicit defense-in-depth denial of all
13
+ * host globals (process, require, Bun, fetch, setTimeout, etc.) and
14
+ * constructor.constructor escape vector blocking.
11
15
  */
12
16
  export function createSandbox(
13
17
  args: unknown,
@@ -29,6 +33,27 @@ export function createSandbox(
29
33
  Function: () => {
30
34
  throw new Error('new Function() is disabled in workflow sandbox.')
31
35
  },
36
+
37
+ // ── P0-2: Defense-in-depth — explicit denial of all host globals ──
38
+ // vm.createContext() does NOT inherit host globals by default, but these
39
+ // explicit undefined entries prevent prototype-chain access and serve as
40
+ // auditable documentation of what is deliberately blocked.
41
+ process: undefined,
42
+ require: undefined,
43
+ import: undefined,
44
+ Bun: undefined,
45
+ fetch: undefined,
46
+ setTimeout: undefined,
47
+ setInterval: undefined,
48
+ queueMicrotask: undefined,
49
+ clearTimeout: undefined,
50
+ clearInterval: undefined,
51
+ globalThis: undefined,
52
+ global: undefined,
53
+ __dirname: undefined,
54
+ __filename: undefined,
55
+ module: undefined,
56
+ exports: undefined,
32
57
  }
33
58
 
34
59
  // Override Date to block now() and argless constructor
@@ -81,6 +106,45 @@ export function createSandbox(
81
106
  })
82
107
  }
83
108
 
109
+ // ── P0-2: Block constructor.constructor escape vector ──
110
+ // The chain [].constructor.constructor('return this')() can escape
111
+ // vm.createContext if not blocked. We override Object to intercept
112
+ // constructor access on prototype chains.
113
+ sandboxObj.Object = new Proxy(Object, {
114
+ get(target, prop) {
115
+ if (prop === 'prototype') {
116
+ return new Proxy(Object.prototype, {
117
+ get(protoTarget, protoProp) {
118
+ if (protoProp === 'constructor') {
119
+ const ctor = Object.prototype.constructor
120
+ return new Proxy(ctor, {
121
+ construct() {
122
+ throw new Error('Dynamic constructor invocation is disabled in workflow sandbox.')
123
+ },
124
+ get(_t, ctorProp) {
125
+ // Block [].constructor.constructor chain
126
+ if (ctorProp === 'constructor') {
127
+ throw new Error('constructor.constructor is disabled in workflow sandbox.')
128
+ }
129
+ const val = (ctor as unknown as Record<string, unknown>)[ctorProp as string]
130
+ return typeof val === 'function'
131
+ ? (val as (...args: unknown[]) => unknown).bind(ctor)
132
+ : val
133
+ },
134
+ })
135
+ }
136
+ const val = (protoTarget as Record<string, unknown>)[protoProp as string]
137
+ return typeof val === 'function'
138
+ ? (val as (...args: unknown[]) => unknown).bind(protoTarget)
139
+ : val
140
+ },
141
+ })
142
+ }
143
+ const val = (Object as unknown as Record<string, unknown>)[prop as string]
144
+ return typeof val === 'function' ? (val as (...args: unknown[]) => unknown).bind(Object) : val
145
+ },
146
+ })
147
+
84
148
  return vm.createContext(sandboxObj)
85
149
  }
86
150