/** * Pure utility functions for plan mode. * Extracted for testability. */ import { hasSubstitution, splitSegments } from '../internal/shell-split.js' // Destructive commands blocked in plan mode. Tested against the segment's command word, // the one word that runs: an allowlisted head never executes its arguments, so `code` // in a path, `touch` in a quoted pattern or `cp` in a file name is not a command. const DESTRUCTIVE_PATTERNS = [ /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i, /\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i, /\bchgrp\b/i, /\bln\b/i, /\btee\b/i, /\btruncate\b/i, /\bdd\b/i, /\bshred\b/i, /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, /\byarn\s+(add|remove|install|publish)/i, /\bpnpm\s+(add|remove|install|publish)/i, /\bpip\s+(install|uninstall)/i, /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, /\bbrew\s+(install|uninstall|upgrade)/i, /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-d|stash|cherry-pick|revert|tag|init|clone)/i, /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i, /\bkillall\b/i, /\breboot\b/i, /\bshutdown\b/i, /\bsystemctl\s+(start|stop|restart|enable|disable)/i, /\bservice\s+\S+\s+(start|stop|restart)/i, /\b(vim?|nano|emacs|code|subl)\b/i, ].map((pattern) => new RegExp(String.raw`^\s*(?:${pattern.source})`, pattern.flags)) // A redirect writes wherever it points, from any position in the segment. const REDIRECT_PATTERNS = [/(^|[^<])>(?!>)/, />>/] // Redirections that write nothing: onto /dev/null, and a descriptor duplicated onto // another (`2>&1`, `>&2`, `>&-`). `>&file` is not one: it writes the file. Every run is // bounded: the text is chosen by the model, and an unbounded `\d*` in front of an // unanchored match backtracks quadratically (40,000 digits took over a second). const DEV_NULL_REDIRECT = /(?:&|\d{0,3})>>?[ \t]{0,8}\/dev\/null(?=\s|$)/g const DESCRIPTOR_REDIRECT = /\d{0,3}>&(?:\d{1,3}|-)(?=\s|$)/g // Safe read-only commands allowed in plan mode. Deliberately excludes env/printenv // (secret disclosure, and env is an exec wrapper), curl/wget (fetch plus -o writes), // awk (system()) and sed (w/W/e write even under -n). const SAFE_PATTERNS = [ /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/, /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*cd(\s|$)/, /^\s*echo\b/, /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/, /^\s*uniq\b/, /^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/, /^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/, /^\s*uname\b/, /^\s*whoami\b/, /^\s*id\b/, /^\s*date\b/, /^\s*cal\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*top\b/, /^\s*htop\b/, /^\s*free\b/, /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, /^\s*git\s+ls-/i, /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, /^\s*yarn\s+(list|info|why|audit)/i, /^\s*node\s+--version/i, /^\s*python\s+--version/i, /^\s*jq\b/, /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*eza\b/, ] // find is allowlisted for traversal only; these actions run commands, delete or write. const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprint0|fprintf)\b/ // Flags that turn an allowlisted read into a write or an execution, per command. const UNSAFE_FLAGS: ReadonlyArray = [ [/^\s*find\b/, FIND_ACTIONS], [/^\s*sort\b/, /\s(-[a-zA-Z]*o|--output\b|--compress-program\b)/], [/^\s*tree\b/, /\s-[a-zA-Z]*o/], [/^\s*rg\b/, /\s--(pre|hostname-bin)\b/], [/^\s*git\s/, /\s--output(=|\s|$)/], ] /** `segment` with its quoted spans removed, so a `>` or a flag inside a pattern reads as * text. Follows splitSegments: a backslash outside quotes escapes the next character, * and inside quotes only the closing quote matters. */ function withoutQuoted(segment: string): string { let bare = '' let quote: string | undefined for (let i = 0; i < segment.length; i++) { const ch = segment[i] if (quote !== undefined) { if (ch === quote) quote = undefined } else if (ch === "'" || ch === '"') { quote = ch } else if (ch === '\\') { i++ } else { bare += ch } } return bare } function writesThroughRedirect(segment: string): boolean { const bare = withoutQuoted(segment).replace(DEV_NULL_REDIRECT, ' ').replace(DESCRIPTOR_REDIRECT, ' ') return REDIRECT_PATTERNS.some((pattern) => pattern.test(bare)) } /** uniq [INPUT [OUTPUT]] writes its second operand. An option's value (-f N, -s N, -w N) * is not an operand. */ function uniqWrites(args: string[]): boolean { let operands = 0 for (let i = 0; i < args.length; i++) { if (['-f', '-s', '-w'].includes(args[i])) i++ else if (!args[i].startsWith('-')) operands++ } return operands >= 2 } const BRANCH_LISTS = /^(-l|--list|--contains|--no-contains|--merged|--no-merged|--points-at)$/ const BRANCH_WRITE_LONG_FLAGS = new Set(['--delete', '--move', '--copy', '--force', '--unset-upstream', '--set-upstream-to', '--edit-description', '--track', '--no-track', '--create-reflog']) /** A `git branch` flag that changes a branch: -d -D -m -M -c -C -u -f -t, alone or in a * cluster, or the long form of one. */ function isBranchWriteFlag(arg: string): boolean { if (arg.startsWith('--')) return BRANCH_WRITE_LONG_FLAGS.has(arg.split('=')[0]) return arg.startsWith('-') && /[dDmMcCuft]/.test(arg) } /** `git branch` lists unless it is given a name to create or a flag that changes one. */ function gitBranchWrites(args: string[]): boolean { if (args.some(isBranchWriteFlag)) return true return args.some((arg) => !arg.startsWith('-')) && !args.some((arg) => BRANCH_LISTS.test(arg)) } /** `git remote` lists and shows; every other subcommand edits the configuration. */ function gitRemoteWrites(args: string[]): boolean { const subcommand = args.find((arg) => !arg.startsWith('-')) return subcommand !== undefined && !['show', 'get-url'].includes(subcommand) } /** Whether an allowlisted command carries a flag or operand that makes it write. */ function writesThroughOperands(segment: string): boolean { const bare = withoutQuoted(segment) if (UNSAFE_FLAGS.some(([head, flag]) => head.test(segment) && flag.test(bare))) return true const [command = '', subcommand = '', ...rest] = segment.trim().split(/\s+/) if (command === 'uniq') return uniqWrites([subcommand, ...rest].filter(Boolean)) if (command !== 'git') return false if (subcommand === 'branch') return gitBranchWrites(rest) return subcommand === 'remote' && gitRemoteWrites(rest) } function isSafeSegment(segment: string): boolean { if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(segment))) return false if (writesThroughRedirect(segment)) return false if (!SAFE_PATTERNS.some((pattern) => pattern.test(segment))) return false return !writesThroughOperands(segment) } /** * Whether plan mode should let this bash command run. * * Model steering, not a sandbox: an allowlisted interpreter can still read and write * whatever the user can, so this narrows the blast radius of a wrong turn rather than * containing a determined one. Only OS-level isolation would be a boundary. */ export function isSafeCommand(command: string): boolean { if (hasSubstitution(command)) return false const segments = splitSegments(command) return segments.length > 0 && segments.every(isSafeSegment) } export interface TodoItem { step: number text: string completed: boolean } /** The plan state persisted in a session entry, once each field has been checked. * A field the restore cannot recognize is simply absent, so the caller keeps its * current value. */ export interface RestoredPlanState { enabled?: boolean todos?: TodoItem[] executing?: boolean savedTools?: string[] } const isTodoItem = (value: unknown): value is TodoItem => { if (value === null || typeof value !== 'object') return false const item = value as Record return typeof item.step === 'number' && typeof item.text === 'string' && typeof item.completed === 'boolean' } /** Read a persisted plan-mode entry, keeping only fields of the expected shape. * The session file is data on disk, and `savedTools` feeds the active tool set: a * string there would be spread character by character into the tool gating, and a * non-array `todos` throws on the first restore that iterates it. */ export function restoredPlanState(data: unknown): RestoredPlanState { if (data === null || typeof data !== 'object') return {} const raw = data as Record const state: RestoredPlanState = {} if (typeof raw.enabled === 'boolean') state.enabled = raw.enabled if (typeof raw.executing === 'boolean') state.executing = raw.executing if (Array.isArray(raw.todos) && raw.todos.every(isTodoItem)) state.todos = raw.todos if (Array.isArray(raw.savedTools) && raw.savedTools.every((tool) => typeof tool === 'string')) state.savedTools = raw.savedTools return state } function cleanStepText(text: string): string { let cleaned = text .replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1') // Remove bold/italic .replace(/`([^`]+)`/g, '$1') // Remove code .replace(/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i, '') .replace(/\s+/g, ' ') .trim() if (cleaned.length > 0) { cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1) } if (cleaned.length > 50) { cleaned = `${cleaned.slice(0, 47)}...` } return cleaned } // Anchored to line start (m flag) so a prose line merely ending in "plan:" is not taken // for the header, which would slice the plan section mid-list and drop earlier steps. // Horizontal whitespace only ([^\S\n]): \s would include \n itself and overlap the // following \n. The runs are bounded rather than unbounded: an unbounded run retried // from every position on a long whitespace-only line is what backtracks super-linearly, // and a real header carries at most a few spaces of indentation. const PLAN_HEADER = /^[^\S\n]{0,8}\*{0,2}Plan:\*{0,2}[^\S\n]{0,8}\n/im const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === '' /** * Text of a `1. step` / `2) step` line, stopping at an inline `*`, or undefined when * the line is not a numbered step. Scanned rather than matched: the equivalent * pattern needs adjacent quantifiers over overlapping classes, which backtracks * super-linearly on a long line that turns out not to be a step. */ function numberedStepText(line: string): string | undefined { let i = 0 while (isBlank(line[i])) i++ const digitsStart = i while (line[i] >= '0' && line[i] <= '9') i++ if (i === digitsStart) return undefined if (line[i] !== '.' && line[i] !== ')') return undefined i++ const spaceStart = i while (isBlank(line[i])) i++ if (i === spaceStart) return undefined // the marker must be followed by whitespace for (let stars = 0; stars < 2 && line[i] === '*'; stars++) i++ const first = line[i] if (first === undefined || first === '*' || isBlank(first)) return undefined const rest = line.slice(i) const star = rest.indexOf('*') return star === -1 ? rest : rest.slice(0, star) } export function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = [] const headerMatch = PLAN_HEADER.exec(message) if (!headerMatch) return items const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length) for (const line of planSection.split('\n')) { const captured = numberedStepText(line) if (captured === undefined) continue const text = captured .trim() .replace(/\*{1,2}$/, '') .trim() if (text.length > 5 && !text.startsWith('`') && !text.startsWith('/') && !text.startsWith('-')) { const cleaned = cleanStepText(text) if (cleaned.length > 3) { items.push({ step: items.length + 1, text: cleaned, completed: false }) } } } return items } function extractDoneSteps(message: string): number[] { const steps: number[] = [] for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) { const step = Number(match[1]) if (Number.isFinite(step)) steps.push(step) } return steps } export function markCompletedSteps(text: string, items: TodoItem[]): number { const doneSteps = extractDoneSteps(text) for (const step of doneSteps) { const item = items.find((t) => t.step === step) if (item) item.completed = true } return doneSteps.length } /** * Parse an explicitly submitted plan (from the plan_mode_complete tool) into * todo items. Unlike extractTodoItems, the Plan: header is optional because * the tool input is already known to be the plan itself. */ export function planToTodos(plan: string): TodoItem[] { const withHeader = PLAN_HEADER.test(plan) ? plan : `Plan:\n${plan}` return extractTodoItems(withHeader) }