@miphamai/cli 0.24.15 → 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 +1 -1
- 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 +30 -1
- package/src/core/permission.ts +92 -0
- package/src/i18n-core/locales/en-US.json +1 -1
- package/src/i18n-core/locales/zh-CN.json +1 -1
- 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 +11 -8
- package/src/workflow/primitives/parallel.ts +55 -2
- package/src/workflow/primitives/pipeline.ts +58 -9
- package/src/workflow/sandbox.ts +64 -0
package/package.json
CHANGED
package/src/agent/message-bus.ts
CHANGED
|
@@ -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
|
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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: [
|
|
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:
|
|
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)}`,
|
package/src/agent/types.ts
CHANGED
|
@@ -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?:
|
|
11
|
+
permissionMode?:
|
|
12
|
+
'default' | 'acceptEdits' | 'auto' | 'bypass' | 'plan' | 'bypassPermissions' | 'dontAsk'
|
|
12
13
|
maxTurns?: number
|
|
13
14
|
skills?: string
|
|
14
15
|
background?: boolean
|
package/src/core/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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 =
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/permission.ts
CHANGED
|
@@ -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(), {
|
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':
|
|
@@ -686,15 +687,17 @@ export function App({
|
|
|
686
687
|
)}
|
|
687
688
|
<Box flexDirection="row">
|
|
688
689
|
<Text color={PERMISSION_COLORS[permissionMode]}>
|
|
689
|
-
⏵⏵ {PERMISSION_LABELS[permissionMode]}
|
|
690
|
+
⏵⏵ {PERMISSION_LABELS[permissionMode]}
|
|
690
691
|
</Text>
|
|
691
|
-
<Text dimColor> · {t('ui.status.esc_to_interrupt')}</Text>
|
|
692
|
-
<Text dimColor> · {t('ui.status.left_for_agents')}</Text>
|
|
693
|
-
</Box>
|
|
694
|
-
{/* Mipham-unique modes (extends Claude Code's 3 → 6 modes) */}
|
|
695
|
-
<Box>
|
|
696
692
|
<Text dimColor>
|
|
697
|
-
{
|
|
693
|
+
{' '}
|
|
694
|
+
({t('ui.status.shift_tab_cycle')}: {PERMISSION_LABELS.default} ·{' '}
|
|
695
|
+
{PERMISSION_LABELS.acceptEdits} · {PERMISSION_LABELS.bypassPermissions} ·{' '}
|
|
696
|
+
{PERMISSION_LABELS.plan} · {PERMISSION_LABELS.auto} · {PERMISSION_LABELS.dontAsk})
|
|
697
|
+
{' · '}
|
|
698
|
+
{t('ui.status.esc_to_interrupt')}
|
|
699
|
+
{' · '}
|
|
700
|
+
{t('ui.status.left_for_agents')}
|
|
698
701
|
</Text>
|
|
699
702
|
</Box>
|
|
700
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
|
|