@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.
- package/package.json +1 -1
- package/skills/standard/codebase-design.SKILL.md +189 -0
- package/skills/standard/domain-modeling.SKILL.md +129 -0
- package/skills/standard/grill-with-docs.SKILL.md +199 -0
- package/skills/standard/to-spec.SKILL.md +138 -0
- package/skills/standard/triage.SKILL.md +155 -0
- package/src/agent/message-bus.ts +5 -0
- package/src/agent/sub-agent.ts +99 -4
- package/src/agent/types.ts +2 -1
- package/src/core/engine.ts +50 -3
- package/src/core/instructions.ts +70 -1
- package/src/core/permission.ts +92 -0
- package/src/index.tsx +9 -2
- package/src/shared/sanitize.ts +182 -0
- package/src/tools/agent/send-message.ts +5 -2
- package/src/tools/exec/bash.ts +40 -3
- package/src/tools/exec/git.ts +71 -4
- package/src/ui/app.tsx +8 -3
- package/src/workflow/primitives/parallel.ts +55 -2
- package/src/workflow/primitives/pipeline.ts +58 -9
- package/src/workflow/sandbox.ts +64 -0
package/src/shared/sanitize.ts
CHANGED
|
@@ -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,
|
|
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: ${
|
|
64
|
+
`Summary: ${truncatedSummary.slice(0, 100)}`,
|
|
62
65
|
}
|
|
63
66
|
},
|
|
64
67
|
}
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -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
|
|
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
|
|
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) {
|
package/src/tools/exec/git.ts
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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: "${
|
|
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
|
-
{' '}
|
|
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
|
-
{' · '}
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
}
|
package/src/workflow/sandbox.ts
CHANGED
|
@@ -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
|
|