@miphamai/cli 0.24.16 → 0.26.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.
@@ -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