@miphamai/cli 0.26.0 → 0.28.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.
@@ -0,0 +1,546 @@
1
+ /**
2
+ * CRSI Phase 3: Security Sandbox for Controlled Code Self-Modification
3
+ *
4
+ * Provides a safe execution environment where AI-generated code changes
5
+ * are:
6
+ * 1. Applied in an isolated git worktree (no effect on working tree)
7
+ * 2. Validated against the full test suite
8
+ * 3. Auto-rolled back on failure
9
+ * 4. Gated by human approval before merging
10
+ *
11
+ * Architecture:
12
+ * AI proposes change → CrsiSandbox applies in worktree → runs tests →
13
+ * if pass → presents diff for human approval → if approved → merges
14
+ * if fail → auto-discards worktree → logs failure for CRSI learning
15
+ */
16
+
17
+ import { execSync } from 'node:child_process'
18
+ import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
+ import { join, resolve } from 'node:path'
20
+ import { tmpdir, homedir } from 'node:os'
21
+ import { randomUUID } from 'node:crypto'
22
+
23
+ // ── Types ──
24
+
25
+ export interface CrsiModification {
26
+ /** Unique ID for this modification attempt */
27
+ id: string
28
+ /** Human-readable description of what this change does */
29
+ description: string
30
+ /** The file path (relative to repo root) to modify */
31
+ filePath: string
32
+ /** The file content after modification */
33
+ newContent: string
34
+ /** The original file content (for rollback verification) */
35
+ originalContent: string
36
+ /** CRSI insight that triggered this modification */
37
+ crsiInsightId?: string
38
+ /** CRSI rule that generated this modification */
39
+ crsiRuleId?: string
40
+ /** Timestamp */
41
+ timestamp: string
42
+ }
43
+
44
+ export interface CrsiModificationResult {
45
+ modification: CrsiModification
46
+ /** Whether the modification was applied successfully */
47
+ applied: boolean
48
+ /** Test results after applying the modification */
49
+ testResult?: CrsiTestResult
50
+ /** Git diff of the change */
51
+ diff?: string
52
+ /** Error message if something failed */
53
+ error?: string
54
+ /** Current phase of the sandbox pipeline */
55
+ phase: 'pending' | 'applied' | 'testing' | 'passed' | 'failed' | 'approved' | 'merged' | 'rolled-back'
56
+ }
57
+
58
+ export interface CrsiTestResult {
59
+ /** Whether all tests passed */
60
+ passed: boolean
61
+ /** Total test count */
62
+ totalTests: number
63
+ /** Failed test count */
64
+ failedTests: number
65
+ /** Test output (truncated) */
66
+ output: string
67
+ }
68
+
69
+ export interface CrsiSessionReport {
70
+ sessionId: string
71
+ modifications: CrsiModificationResult[]
72
+ summary: {
73
+ total: number
74
+ applied: number
75
+ passed: number
76
+ merged: number
77
+ rolledBack: number
78
+ }
79
+ startedAt: string
80
+ completedAt?: string
81
+ }
82
+
83
+ // ── Constants ──
84
+
85
+ const WORKTREE_PREFIX = 'crsi-sandbox-'
86
+ const MAX_WORKTREE_AGE_MS = 30 * 60 * 1000 // 30 minutes
87
+ const TEST_TIMEOUT_MS = 120_000 // 2 minutes
88
+ const REPORT_DIR = join(homedir(), '.mipham', 'crsi-sandbox')
89
+
90
+ // ── Sandbox ──
91
+
92
+ export class CrsiSandbox {
93
+ private repoRoot: string
94
+ private worktreePath?: string
95
+ private worktreeBranch?: string
96
+ private sessionReport: CrsiSessionReport
97
+ private currentModification?: CrsiModification
98
+
99
+ constructor(repoRoot: string = process.cwd()) {
100
+ this.repoRoot = resolve(repoRoot)
101
+ this.sessionReport = {
102
+ sessionId: `crsi-session-${Date.now().toString(36)}`,
103
+ modifications: [],
104
+ summary: { total: 0, applied: 0, passed: 0, merged: 0, rolledBack: 0 },
105
+ startedAt: new Date().toISOString(),
106
+ }
107
+
108
+ // Ensure report directory exists
109
+ mkdirSync(REPORT_DIR, { recursive: true })
110
+ }
111
+
112
+ // ── Public API ──
113
+
114
+ /**
115
+ * Stage 1: Create an isolated git worktree for safe modification.
116
+ *
117
+ * Creates a temporary worktree on a new branch. All subsequent
118
+ * modifications happen in this isolated environment — the user's
119
+ * working tree is never touched.
120
+ */
121
+ createWorktree(): { worktreePath: string; branch: string } {
122
+ // Clean up any stale worktrees from previous crashed sessions
123
+ this.cleanupStaleWorktrees()
124
+
125
+ const branchName = `${WORKTREE_PREFIX}${randomUUID().slice(0, 8)}`
126
+ const worktreeDir = join(tmpdir(), branchName)
127
+
128
+ // Ensure the dir doesn't already exist
129
+ if (existsSync(worktreeDir)) {
130
+ rmSync(worktreeDir, { recursive: true, force: true })
131
+ }
132
+
133
+ try {
134
+ execSync(`git worktree add -b "${branchName}" "${worktreeDir}" HEAD`, {
135
+ cwd: this.repoRoot,
136
+ timeout: 30_000,
137
+ encoding: 'utf-8',
138
+ })
139
+ } catch (err) {
140
+ throw new Error(`Failed to create CRSI worktree: ${String(err)}`)
141
+ }
142
+
143
+ this.worktreePath = worktreeDir
144
+ this.worktreeBranch = branchName
145
+
146
+ return { worktreePath: worktreeDir, branch: branchName }
147
+ }
148
+
149
+ /**
150
+ * Stage 2: Apply a modification in the sandbox worktree.
151
+ *
152
+ * Writes the new content to the target file inside the worktree.
153
+ * The modification is NOT applied to the user's working tree.
154
+ */
155
+ applyModification(mod: CrsiModification): CrsiModificationResult {
156
+ if (!this.worktreePath) {
157
+ return { modification: mod, applied: false, phase: 'pending', error: 'No worktree created. Call createWorktree() first.' }
158
+ }
159
+
160
+ this.sessionReport.summary.total++
161
+ this.currentModification = mod
162
+
163
+ const result: CrsiModificationResult = {
164
+ modification: mod,
165
+ applied: false,
166
+ phase: 'pending',
167
+ }
168
+
169
+ const targetPath = join(this.worktreePath, mod.filePath)
170
+
171
+ // Verify the file exists in the worktree
172
+ if (!existsSync(targetPath)) {
173
+ result.error = `File not found in worktree: ${mod.filePath}`
174
+ result.phase = 'failed'
175
+ this.sessionReport.modifications.push(result)
176
+ return result
177
+ }
178
+
179
+ // Verify original content matches (safety check)
180
+ try {
181
+ const currentContent = readFileSync(targetPath, 'utf-8')
182
+ if (currentContent !== mod.originalContent && mod.originalContent) {
183
+ result.error = 'Original content mismatch — file may have been modified since the audit. Aborting.'
184
+ result.phase = 'failed'
185
+ this.sessionReport.modifications.push(result)
186
+ return result
187
+ }
188
+ } catch {
189
+ // If we can't read the file, proceed anyway — it's a worktree
190
+ }
191
+
192
+ // Apply the modification
193
+ try {
194
+ writeFileSync(targetPath, mod.newContent, 'utf-8')
195
+ result.applied = true
196
+ result.phase = 'applied'
197
+ this.sessionReport.summary.applied++
198
+
199
+ // Generate diff
200
+ try {
201
+ result.diff = execSync(`git diff -- "${mod.filePath}"`, {
202
+ cwd: this.worktreePath,
203
+ timeout: 10_000,
204
+ encoding: 'utf-8',
205
+ })
206
+ } catch {
207
+ // Diff generation is best-effort
208
+ }
209
+ } catch (err) {
210
+ result.error = `Failed to apply modification: ${String(err)}`
211
+ result.phase = 'failed'
212
+ }
213
+
214
+ this.sessionReport.modifications.push(result)
215
+ return result
216
+ }
217
+
218
+ /**
219
+ * Stage 3: Run the test suite against the modified worktree.
220
+ *
221
+ * Only modifications that have been successfully applied can be tested.
222
+ * If tests fail, the modification is marked as 'failed' and will be
223
+ * rolled back.
224
+ */
225
+ runTests(): CrsiTestResult {
226
+ if (!this.worktreePath) {
227
+ return { passed: false, totalTests: 0, failedTests: 0, output: 'No worktree available.' }
228
+ }
229
+
230
+ const result: CrsiTestResult = { passed: false, totalTests: 0, failedTests: 0, output: '' }
231
+
232
+ try {
233
+ const output = execSync('pnpm test 2>&1', {
234
+ cwd: this.worktreePath,
235
+ timeout: TEST_TIMEOUT_MS,
236
+ encoding: 'utf-8',
237
+ })
238
+
239
+ result.output = output.slice(-5000) // Keep last 5000 chars
240
+
241
+ // Parse test results from vitest output
242
+ const testMatch = output.match(/Tests\s+(\d+)\s+passed/)
243
+ const failMatch = output.match(/(\d+)\s+failed/)
244
+ const totalMatch = output.match(/Tests\s+(\d+)\s+passed.*?(\d+)\s+total/)
245
+
246
+ if (totalMatch) {
247
+ result.totalTests = parseInt(totalMatch[2]!, 10)
248
+ }
249
+ if (testMatch && !failMatch) {
250
+ result.passed = true
251
+ result.failedTests = 0
252
+ } else if (failMatch) {
253
+ result.failedTests = parseInt(failMatch[1]!, 10)
254
+ }
255
+
256
+ // Update all pending modifications to 'testing' → 'passed'/'failed'
257
+ for (const modResult of this.sessionReport.modifications) {
258
+ if (modResult.phase === 'applied') {
259
+ modResult.phase = 'testing'
260
+ modResult.testResult = result
261
+ }
262
+ if (modResult.phase === 'testing') {
263
+ modResult.phase = result.passed ? 'passed' : 'failed'
264
+ if (result.passed) {
265
+ this.sessionReport.summary.passed++
266
+ }
267
+ }
268
+ }
269
+ } catch (err) {
270
+ result.output = String(err).slice(0, 5000)
271
+ result.failedTests = 1
272
+
273
+ // Mark all applied modifications as failed
274
+ for (const modResult of this.sessionReport.modifications) {
275
+ if (modResult.phase === 'applied' || modResult.phase === 'testing') {
276
+ modResult.phase = 'failed'
277
+ modResult.testResult = result
278
+ }
279
+ }
280
+ }
281
+
282
+ return result
283
+ }
284
+
285
+ /**
286
+ * Stage 4: Get the human-approvable diff of all modifications.
287
+ *
288
+ * Returns the full git diff so a human can review before approving.
289
+ */
290
+ getDiff(): string {
291
+ if (!this.worktreePath) return ''
292
+
293
+ try {
294
+ return execSync('git diff', {
295
+ cwd: this.worktreePath,
296
+ timeout: 10_000,
297
+ encoding: 'utf-8',
298
+ })
299
+ } catch {
300
+ return '(diff unavailable)'
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Stage 5: Merge modifications back to the main repo.
306
+ *
307
+ * ONLY call this after human approval and successful test run.
308
+ * Cherry-picks the changes from the worktree branch to the current branch.
309
+ */
310
+ merge(): { success: boolean; message: string } {
311
+ if (!this.worktreePath || !this.worktreeBranch) {
312
+ return { success: false, message: 'No worktree to merge from.' }
313
+ }
314
+
315
+ // Check that all modifications passed tests
316
+ const failedCount = this.sessionReport.modifications.filter(
317
+ (m) => m.phase === 'failed',
318
+ ).length
319
+ if (failedCount > 0) {
320
+ return {
321
+ success: false,
322
+ message: `${failedCount} modification(s) failed tests. Cannot merge.`,
323
+ }
324
+ }
325
+
326
+ try {
327
+ // Commit in worktree
328
+ execSync('git add -A', { cwd: this.worktreePath, timeout: 10_000 })
329
+ execSync(
330
+ `git commit -m "CRSI auto-modification: ${this.sessionReport.sessionId}" --allow-empty`,
331
+ { cwd: this.worktreePath, timeout: 10_000 },
332
+ )
333
+
334
+ // Cherry-pick to main repo
335
+ const commitHash = execSync('git rev-parse HEAD', {
336
+ cwd: this.worktreePath,
337
+ timeout: 10_000,
338
+ encoding: 'utf-8',
339
+ }).trim()
340
+
341
+ execSync(`git cherry-pick "${commitHash}"`, {
342
+ cwd: this.repoRoot,
343
+ timeout: 30_000,
344
+ })
345
+
346
+ // Mark as merged
347
+ for (const modResult of this.sessionReport.modifications) {
348
+ if (modResult.phase === 'passed' || modResult.phase === 'approved') {
349
+ modResult.phase = 'merged'
350
+ }
351
+ }
352
+ this.sessionReport.summary.merged = this.sessionReport.modifications.filter(
353
+ (m) => m.phase === 'merged',
354
+ ).length
355
+
356
+ return {
357
+ success: true,
358
+ message: `Successfully merged ${this.sessionReport.summary.merged} modification(s).`,
359
+ }
360
+ } catch (err) {
361
+ return { success: false, message: `Merge failed: ${String(err)}` }
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Stage 5-alt: Roll back all modifications.
367
+ *
368
+ * Discards the worktree and all changes. Called automatically when
369
+ * tests fail, or manually by the user if they reject the diff.
370
+ */
371
+ rollback(): { success: boolean; message: string } {
372
+ if (!this.worktreePath || !this.worktreeBranch) {
373
+ return { success: false, message: 'No worktree to roll back.' }
374
+ }
375
+
376
+ // Mark all modifications as rolled-back
377
+ for (const modResult of this.sessionReport.modifications) {
378
+ if (modResult.phase !== 'merged') {
379
+ modResult.phase = 'rolled-back'
380
+ }
381
+ }
382
+ this.sessionReport.summary.rolledBack = this.sessionReport.modifications.filter(
383
+ (m) => m.phase === 'rolled-back',
384
+ ).length
385
+
386
+ // Remove the worktree
387
+ this.removeWorktree()
388
+
389
+ return {
390
+ success: true,
391
+ message: `Rolled back ${this.sessionReport.summary.rolledBack} modification(s).`,
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Clean up: remove the worktree and its branch.
397
+ * Safe to call at any time — does nothing if no worktree exists.
398
+ */
399
+ removeWorktree(): void {
400
+ if (!this.worktreePath || !this.worktreeBranch) return
401
+
402
+ try {
403
+ // Remove worktree
404
+ execSync(`git worktree remove --force "${this.worktreePath}"`, {
405
+ cwd: this.repoRoot,
406
+ timeout: 15_000,
407
+ })
408
+ } catch {
409
+ // Best-effort: if worktree remove fails, try manual cleanup
410
+ try {
411
+ if (existsSync(this.worktreePath)) {
412
+ rmSync(this.worktreePath, { recursive: true, force: true })
413
+ }
414
+ execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 10_000 })
415
+ } catch {
416
+ // Give up — stale worktrees will be cleaned up on next createWorktree()
417
+ }
418
+ }
419
+
420
+ // Delete the branch
421
+ try {
422
+ execSync(`git branch -D "${this.worktreeBranch}"`, {
423
+ cwd: this.repoRoot,
424
+ timeout: 10_000,
425
+ })
426
+ } catch {
427
+ // Branch may already be gone
428
+ }
429
+
430
+ this.worktreePath = undefined
431
+ this.worktreeBranch = undefined
432
+ }
433
+
434
+ /**
435
+ * Generate and persist the session report.
436
+ */
437
+ finalize(): CrsiSessionReport {
438
+ this.sessionReport.completedAt = new Date().toISOString()
439
+
440
+ // Persist to ~/.mipham/crsi-sandbox/
441
+ const reportPath = join(REPORT_DIR, `${this.sessionReport.sessionId}.json`)
442
+ try {
443
+ writeFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), 'utf-8')
444
+ } catch {
445
+ // Best-effort persistence
446
+ }
447
+
448
+ // Clean up worktree
449
+ this.removeWorktree()
450
+
451
+ return this.sessionReport
452
+ }
453
+
454
+ /**
455
+ * Get the current session report (for live status checks).
456
+ */
457
+ getReport(): CrsiSessionReport {
458
+ return this.sessionReport
459
+ }
460
+
461
+ /**
462
+ * Load a historical session report.
463
+ */
464
+ static loadReport(sessionId: string): CrsiSessionReport | null {
465
+ const reportPath = join(REPORT_DIR, `${sessionId}.json`)
466
+ if (!existsSync(reportPath)) return null
467
+ try {
468
+ return JSON.parse(readFileSync(reportPath, 'utf-8'))
469
+ } catch {
470
+ return null
471
+ }
472
+ }
473
+
474
+ /**
475
+ * List all session reports.
476
+ */
477
+ static listReports(): string[] {
478
+ if (!existsSync(REPORT_DIR)) return []
479
+ try {
480
+ return readdirSync(REPORT_DIR)
481
+ .filter((f: string) => f.endsWith('.json'))
482
+ .map((f: string) => f.replace('.json', ''))
483
+ } catch {
484
+ return []
485
+ }
486
+ }
487
+
488
+ // ── Private Helpers ──
489
+
490
+ /**
491
+ * Remove stale worktrees from previous sessions that may have crashed
492
+ * before cleanup.
493
+ */
494
+ private cleanupStaleWorktrees(): void {
495
+ try {
496
+ const listOutput = execSync('git worktree list --porcelain', {
497
+ cwd: this.repoRoot,
498
+ timeout: 10_000,
499
+ encoding: 'utf-8',
500
+ })
501
+
502
+ const worktrees = this.parseWorktreeList(listOutput)
503
+
504
+ for (const wt of worktrees) {
505
+ // Only clean up CRSI sandbox worktrees
506
+ if (!wt.branch.includes(WORKTREE_PREFIX)) continue
507
+
508
+ // Check if the worktree path still exists
509
+ if (!existsSync(wt.path)) {
510
+ // Stale entry — prune it
511
+ try {
512
+ execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 5_000 })
513
+ execSync(`git branch -D "${wt.branch}"`, { cwd: this.repoRoot, timeout: 5_000 })
514
+ } catch {
515
+ // Best-effort cleanup
516
+ }
517
+ }
518
+ }
519
+ } catch {
520
+ // Not a git repo or worktree command failed — skip cleanup
521
+ }
522
+ }
523
+
524
+ private parseWorktreeList(output: string): Array<{ path: string; branch: string }> {
525
+ const worktrees: Array<{ path: string; branch: string }> = []
526
+ let current: { path?: string; branch?: string } = {}
527
+
528
+ for (const line of output.split('\n')) {
529
+ if (line.startsWith('worktree ')) {
530
+ if (current.path && current.branch) {
531
+ worktrees.push({ path: current.path, branch: current.branch })
532
+ }
533
+ current = { path: line.slice('worktree '.length) }
534
+ }
535
+ if (line.startsWith('branch ')) {
536
+ current.branch = line.slice('branch '.length).replace('refs/heads/', '')
537
+ }
538
+ }
539
+
540
+ if (current.path && current.branch) {
541
+ worktrees.push({ path: current.path, branch: current.branch })
542
+ }
543
+
544
+ return worktrees
545
+ }
546
+ }
@@ -14,6 +14,7 @@ import type { ArtifactServer } from '../artifacts/server'
14
14
  import type { AgentRegistry } from '../agent/agent-registry'
15
15
  import { analyzeForMemory } from './memory/memory-writer'
16
16
  import { getMemoryManager } from './memory/memory-loader'
17
+ import { AutoMemoryEngine } from './auto-memory.js'
17
18
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
18
19
  import type { SkillsLoader } from '../skills/loader'
19
20
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
@@ -45,6 +46,7 @@ export class QueryEngine {
45
46
  private ruleEngine?: ExperienceRuleEngine
46
47
  private _patternAnalyzer?: PatternAnalyzer
47
48
  private _effectivenessTracker?: EffectivenessTracker
49
+ private _autoMemory?: AutoMemoryEngine
48
50
  private goal?: string
49
51
  private maxGoalLoops = 20
50
52
  private lastAssistantContent?: string
@@ -1017,6 +1019,24 @@ export class QueryEngine {
1017
1019
  return this._effectivenessTracker
1018
1020
  }
1019
1021
 
1022
+ /**
1023
+ * CRSI Phase 2: Lazily-initialized AutoMemoryEngine singleton.
1024
+ * Wires the CRSI Phase 1 pipeline (PatternAnalyzer, RuleEngine, EffectivenessTracker)
1025
+ * into the auto-reflection engine so that post-turn insights can feed rule generation.
1026
+ */
1027
+ getAutoMemory(): AutoMemoryEngine {
1028
+ if (!this._autoMemory) {
1029
+ this._autoMemory = new AutoMemoryEngine()
1030
+ // Wire CRSI Phase 1 → Phase 2 bridge
1031
+ this._autoMemory.setCrsiPipeline(
1032
+ this.getPatternAnalyzer(),
1033
+ this.getRuleEngine() || new ExperienceRuleEngine(),
1034
+ this.getEffectivenessTracker(),
1035
+ )
1036
+ }
1037
+ return this._autoMemory
1038
+ }
1039
+
1020
1040
  /** Register a tool dynamically (used by MCP auto-registration). */
1021
1041
  registerTool(tool: ToolDefinition): void {
1022
1042
  if (this.tools.has(tool.name)) {
@@ -9,6 +9,53 @@ import type { PermissionRuleEntry } from '../shared/index.ts'
9
9
  import { matchBashRule, compileRule } from './permission-rules'
10
10
  import { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
11
11
 
12
+ /**
13
+ * Check if a Bash command is a "verification-only" command that should be
14
+ * auto-approved in acceptEdits mode. These are non-destructive read/check
15
+ * operations that form the core of the vibe coding edit→test→fix loop.
16
+ */
17
+ function isVerificationCommand(input: Record<string, unknown>): boolean {
18
+ const cmd = (input.command as string) || ''
19
+ // Patterns for verification-only commands (no side effects on codebase)
20
+ const verifyPatterns = [
21
+ /\bpnpm\s+test\b/, // test runner
22
+ /\bpnpm\s+t\b/, // shorthand test
23
+ /\bpnpm\s+typecheck\b/, // type checking
24
+ /\bpnpm\s+lint\b/, // linting
25
+ /\bpnpm\s+format:check\b/, // format check
26
+ /\bnpm\s+test\b/, // npm test
27
+ /\bnpm\s+run\s+test\b/, // npm run test
28
+ /\bvitest\b/, // vitest runner
29
+ /\bjest\b/, // jest runner
30
+ /\btsc\s+(?!init)/, // TypeScript compiler (not tsc init)
31
+ /\btsc\s+--noEmit\b/, // type check only
32
+ /\beslint\b/, // eslint
33
+ /\bprettier\s+--check\b/, // prettier check
34
+ /\bpytest\b/, // python test runner
35
+ /\bruff\s+check\b/, // python linter
36
+ /\bcargo\s+test\b/, // rust test
37
+ /\bcargo\s+check\b/, // rust check
38
+ /\bgo\s+test\b/, // go test
39
+ /\bgo\s+vet\b/, // go vet
40
+ /\bmake\s+test\b/, // make test
41
+ /\bgit\s+status\b/, // git status (read-only)
42
+ /\bgit\s+diff\b/, // git diff (read-only)
43
+ /\bgit\s+log\b/, // git log (read-only)
44
+ /\bgit\s+branch\b/, // git branch (read-only)
45
+ /\bls\b/, // list files
46
+ /\bcat\b/, // read file
47
+ /\bhead\b/, // read file start
48
+ /\btail\b/, // read file end
49
+ /\bwhich\b/, // find binary
50
+ /\becho\b/, // print text
51
+ /\bnode\s+-v\b/, // node version
52
+ /\bpython\s+--version\b/, // python version
53
+ /\bwhoami\b/, // current user
54
+ /\bpwd\b/, // current directory
55
+ ]
56
+ return verifyPatterns.some((p) => p.test(cmd))
57
+ }
58
+
12
59
  const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
13
60
 
14
61
  export class PermissionSystem {
@@ -276,7 +323,7 @@ export class PermissionSystem {
276
323
  }
277
324
 
278
325
  // 5. Mode baseline
279
- const baseline = this.modeBaseline(tool)
326
+ const baseline = this.modeBaseline(tool, input)
280
327
  if (baseline !== 'mode-baseline') {
281
328
  this.checkCache.set(cacheKey, baseline)
282
329
  return baseline
@@ -323,21 +370,29 @@ export class PermissionSystem {
323
370
  return rule.pattern === tool.name || rule.compiled.test(tool.name)
324
371
  }
325
372
 
326
- private modeBaseline(tool: ToolDefinition): PermissionLevel | 'mode-baseline' {
373
+ private modeBaseline(
374
+ tool: ToolDefinition,
375
+ input?: Record<string, unknown>,
376
+ ): PermissionLevel | 'mode-baseline' {
327
377
  switch (this.mode) {
328
378
  case 'default':
329
379
  // Delegate to tool.permission (backward compat)
330
380
  return 'mode-baseline'
331
381
 
332
382
  case 'acceptEdits':
333
- // Reads + file edits free; Bash requires approval
334
- return tool.category === 'file'
335
- ? ['Bash'].includes(tool.name)
336
- ? 'ask'
337
- : 'bypass'
338
- : tool.name === 'Bash'
339
- ? 'ask'
340
- : 'ask'
383
+ // Reads + file edits free; Bash auto-approved for verification commands
384
+ if (tool.category === 'file' && tool.name !== 'Bash') {
385
+ return 'bypass'
386
+ }
387
+ if (tool.name === 'Bash') {
388
+ // Vibe coding fix: auto-approve verification commands
389
+ // so the edit→test→fix loop isn't interrupted by permission prompts
390
+ if (input && isVerificationCommand(input)) {
391
+ return 'bypass'
392
+ }
393
+ return 'ask'
394
+ }
395
+ return 'ask'
341
396
 
342
397
  case 'plan':
343
398
  // Only reads, no writes or executes
@@ -80,12 +80,12 @@ export const enterPlanModeTool: ToolDefinition = {
80
80
  ' ⚠️ All other tools — require confirmation',
81
81
  '',
82
82
  'Design your approach, explore the codebase, then:',
83
- ' • Use ExitPlanMode to submit your plan for approval',
84
- ' • The user will review and approve before code changes begin',
83
+ ' • Use ExitPlanMode to submit your plan for user review',
84
+ ' • Present your plan and ask for explicit approval',
85
+ ' • The user must say "approved" before you switch to acceptEdits mode',
85
86
  '',
86
- 'ExitPlanMode parameters:',
87
- ' approved: true → switch to acceptEdits mode (code changes allowed)',
88
- ' approved: false → revert to default mode',
87
+ '⚠️ You CANNOT self-approve your plan. The user must explicitly confirm.',
88
+ ' After ExitPlanMode, wait for user approval before making code changes.',
89
89
  ].join('\n'),
90
90
  }
91
91
  },