Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
b6dddf934This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* What a dialog asks, read on the machine that owns it (daemons/BRAIN.md, "the floor"). Three questions:
*
* DENY-CLASS Never approved by a key, a rule, a recommendation or the pair: pushes, force flags,
* destructive deletes and resets, sudo, piping a download into a shell, deploy, publish,
* drop, merge. Read over the WHOLE dialog — every line of the command, its prose and its
* options — because a wrapped `&& git push` sits on the second line. It leans wide: a false
* positive costs a key, a false negative approves a push.
* ALLOW-CLASS What a `[y]` key may approve at all: a permission prompt for a read, a test, a build, a
* linter or formatter, or an edit to a file in the project. Everything else — including every
* question that is not a permission prompt, and every prompt this file cannot read with
* CERTAINTY — gets `[g]` (open the pane) and no `[y]`. It leans narrow.
* PERSISTENT An option that answers more than this once ("don't ask again", "allow all edits during
* this session", "always"). The daemon never picks one, for any caller: `y` is a one-time yes.
*
* How a command is read (pair/shell.ts): one line only; split on `;`, `&`, `&&`, `||`, `|`; refused on any
* expansion (`$`, backticks, `<(`, `>(`), heredoc, subshell, brace or comment; an environment prefix
* (`X=1 cmd`) is not allow-class; a redirection may only fold descriptors (`2>&1`) or write to /dev/null.
* Each simple command must be on the allow-list below, with none of its write- or exec-capable forms, and
* every path it names must resolve (symlinks and all) inside the project.
*
* The structured tool call, when the engine's transcript has it (Claude Code's `tool_use`, Codex's
* `function_call`), is preferred over the painted dialog: it has the exact command, not a wrapped one.
*/
import { lstatSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { parseShell, type ShellCommand, type ShellWord } from './shell.js'
const DENY_PATTERNS: RegExp[] = [
/\bpush(ed|es|ing)?\b/i, // git push in any form
/--force(-with-lease|-if-includes)?\b|\bforce[- ]?(push|with-lease)?\b|(^|\s)-f(\s|$)/i,
/\bbranch\s+(-[a-zA-Z]*D|--delete\s+--force)\b/, // git branch -D
/\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|-[a-zA-Z]*\s+-[a-zA-Z]*[rR])/, // rm -r, -rf, -fr, -R, --recursive
/\breset\s+--hard\b/i,
/\bclean\s+-[a-zA-Z]*f/i, // git clean -f, -fd, -fdx
/\bsudo\b|\bdoas\b/i,
/\|\s*(sudo\s+)?(ba|z|da|k|c|tc|fi)?sh\b/i, // | sh, | bash
/\bch(mod|own|grp)\s+(-[a-zA-Z]*R|--recursive)/,
/\bmkfs(\.\w+)?\b/i,
/\bdeploy(s|ed|ing|ment)?\b/i,
/\bpublish(es|ed|ing)?\b/i,
/\bdrop\s+(table|database|schema|index|column|collection|view)\b|\bdropdb\b|\bdrop\b(?!down)/i,
/\btruncate\s+table\b/i,
/\bmerge(s|d)?\b/i,
]
/** `first` somewhere in `text`, and `then` true of the text from there on (across lines too: it leans wide). */
const after = (text: string, first: RegExp, then: (rest: string) => boolean): boolean => {
const at = first.exec(text)
return !!at && then(text.slice(at.index))
}
/** Deny checks that one regex would make slow — two runs that can both take blanks backtrack for every start
* (`dd` and 200,000 blanks took 19 s) — scanned in linear time instead. */
const DENY_SCANS: Array<(text: string) => boolean> = [
(text) => after(text, /\b(curl|wget|fetch)\b/i, (rest) => rest.includes('|')), // a download piped anywhere
(text) => after(text, /\bdd\s/i, (rest) => /\bif=/i.test(rest)), // dd reading a device or file
]
/** A deny-class dialog: never approved by anything but the person's own hands in the pane. */
export function isDenyClass(dialog: string, options: readonly string[] = []): boolean {
const text = [dialog, ...options].join('\n')
return DENY_PATTERNS.some((pattern) => pattern.test(text)) || DENY_SCANS.some((scan) => scan(text))
}
/** Options that answer for more than this once, and the one-time yes (pair/floor.ts, where the floor uses them). */
export { isOneTimeYes, isPersistentOption } from './floor.js'
// ── paths ───────────────────────────────────────────────────────────────────────────────────────────
export interface PathContext {
/** The harness's folder: the project. Without it nothing resolves, and nothing is in the project. */
cwd?: string | null
/** This computer's home folder (os.homedir() when absent): `~`, and the dotfiles nothing may touch. */
home?: string | null
}
/** Folders no daemon key ever approves a read or an edit in, wherever they sit: the daemon's own data, git's
* internals (hooks, config) and the engines' settings (their hooks run commands). */
const PROTECTED_SEGMENTS = new Set(['.harness', '.git', '.claude', '.codex'])
/** A name as a case- and Unicode-folding file system (APFS, NTFS) matches it: `.GIT`, `.Claude` and `.harneſs`
* open `.git`, `.claude` and `.harness` there, so they are compared folded. */
const folded = (segment: string): string => segment.normalize('NFKC').toLowerCase()
/** A path longer than this cannot be opened (ENAMETOOLONG on macOS and Linux): nothing past it is read with certainty. */
const PATH_MAX = 4096
/**
* `path` as the kernel resolves it: the longest part of it that exists through realpath(3) — every symlink, and a
* `..` after one taken from where the link really points (never folded away before the links are read: `link/..`
* is the parent of the link's target) — and the part that does not exist yet appended as written. Null (not
* certain) when that part holds a `..`, or starts at a symlink whose target is missing (a write would follow it).
*/
function realResolve(path: string): string | null {
if (path.length > PATH_MAX || !isAbsolute(path)) return null
const parts = path.split(sep).filter((part) => part && part !== '.')
let at = parts.length
let real: string | null = null
while (real === null && at >= 0) {
try { real = realpathSync.native(sep + parts.slice(0, at).join(sep)) } catch { at-- }
}
if (real === null) return null
const rest = parts.slice(at)
if (!rest.length) return real
if (rest.includes('..')) return null
try { if (lstatSync(join(real, rest[0]!)).isSymbolicLink()) return null } catch { /* truly absent */ }
return join(real, ...rest)
}
/**
* Whether `path` is in the project, for a key to approve reading or editing it: resolved against the
* harness's folder, every symlink along it (the target and every parent) resolved, and still inside that
* folder afterwards — and not under `.git/`, `.harness/`, `.claude/`, `.codex/`, not a dotfile or dot-folder
* of the home folder, not a file with a second hard link. Anything it cannot resolve is not in the project.
*/
export function inProject(path: string, cwd?: string | null, opts: { home?: string | null } = {}): boolean {
const p = path.trim()
if (!p || !cwd || !isAbsolute(cwd)) return false
const home = opts.home === undefined ? homedir() : opts.home
let target: string
if (p === '~' || p.startsWith('~/')) {
if (!home) return false
target = `${resolve(home)}${sep}${p.slice(1)}`
} else if (p.startsWith('~')) {
return false // ~someone
} else {
// Joined as written, never normalized: `link/../x` is resolved by realResolve as the kernel would.
target = isAbsolute(p) ? p : `${cwd}${sep}${p}`
}
const realCwd = realResolve(cwd)
const realTarget = realResolve(target)
if (!realCwd || !realTarget) return false
const rel = relative(realCwd, realTarget)
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return false
if (rel.split(sep).some((segment) => PROTECTED_SEGMENTS.has(folded(segment)))) return false
const realHome = home ? realResolve(resolve(home)) : null
if (realHome) {
const fromHome = relative(realHome, realTarget)
if (fromHome && fromHome !== '..' && !fromHome.startsWith(`..${sep}`) && !isAbsolute(fromHome) && folded(fromHome.split(sep)[0]!).startsWith('.')) return false
}
// A second hard link: an edit here lands in another file too (a link to ~/.bashrc, say).
try {
const stat = lstatSync(realTarget)
if (stat.isFile() && stat.nlink > 1) return false
} catch { /* a file to be created */ }
return true
}
/** A path as a dialog paints it: quotes around it, and a question's own punctuation after it. */
function paintedPath(text: string): string {
const p = text.trim().replace(/^["'`]|["'`]$/g, '').replace(/[?,:]$/, '')
return p.endsWith('.') && !p.endsWith('..') && p !== '.' ? p.slice(0, -1) : p
}
// ── allow-class commands ────────────────────────────────────────────────────────────────────────────
interface Ctx { cwd: string | null; home: string | null }
/** Reads with no option that writes or runs anything. Every argument that is a path must be in the project. */
const READS = new Set(['ls', 'pwd', 'cat', 'head', 'tail', 'wc', 'grep', 'egrep', 'fgrep', 'stat', 'du', 'df', 'cut', 'tr', 'jq',
'diff', 'cmp', 'basename', 'dirname', 'realpath', 'nl', 'column'])
/** Words, not paths: nothing they print is a file. */
const TEXT = new Set(['echo', 'printf', 'true', 'which', 'type'])
/** Their first operand is a pattern or a program, not a path. */
const PATTERN_FIRST = new Set(['grep', 'egrep', 'fgrep', 'jq'])
const TEST_TOOLS = new Set(['vitest', 'jest', 'mocha', 'tsc', 'eslint', 'prettier', 'biome', 'stylelint'])
const PY_TOOLS = new Set(['pytest', 'mypy', 'ruff', 'black', 'isort', 'flake8', 'pylint', 'pyright'])
const PY_MODULES = new Set(['pytest', 'unittest', 'mypy', 'ruff', 'black', 'isort', 'compileall'])
const SCRIPT = /^(test|tests|check|lint|typecheck|type-check|tsc|build|format|fmt|prettier)(:[\w:-]+)?$/
const MAKE_TARGETS = new Set(['test', 'tests', 'check', 'build', 'lint', 'fmt', 'format', 'all'])
const GIT_READS = new Set(['status', 'diff', 'log', 'show', 'rev-parse', 'ls-files', 'ls-tree', 'blame', 'describe', 'shortlog', 'grep'])
const GIT_BRANCH_LIST = new Set(['-a', '-r', '-v', '-vv', '--all', '--remotes', '--list', '--show-current'])
/** git options that write a file, run a pager or a driver, or point git somewhere else. */
const GIT_REFUSED_LONG = ['--output', '--open-files-in-pager', '--ext-diff', '--exec-path', '--git-dir', '--work-tree', '--config-env', '--upload-pack', '--receive-pack']
const FIND_ACTIONS = new Set(['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls'])
const FIND_VALUE_ARGS = new Set(['-name', '-iname', '-path', '-ipath', '-regex', '-iregex', '-wholename', '-iwholename', '-type',
'-maxdepth', '-mindepth', '-size', '-mtime', '-mmin', '-newermt', '-perm', '-user', '-group'])
/** `sed -n 10,20p`, `sed -n '/a/,/b/p'`: printing lines — never `w`, `e`, `r`, `-i`, `-e` or `-f`. */
const SED_PRINT = /^((\d+|\$|\/[^/\\]*\/)(,(\d+|\$|\/[^/\\]*\/))?)?p$/
const isFlag = (w: ShellWord): boolean => w.text.startsWith('-') && w.text !== '-'
/** The letters of a short-option cluster, up to the value one of them may carry in the same word: `-uo/tmp/x` → `uo`. */
const shortLetters = (text: string): string => /^-([A-Za-z0-9]+)/.exec(text)?.[1] ?? ''
/** One of `longs`, spelled out or abbreviated (getopt_long and git take any unique prefix: `--out=x` is `--output`). */
const isLong = (text: string, longs: readonly string[]): boolean => {
if (!text.startsWith('--')) return false
const name = text.split('=')[0]!
return name.length > 2 && longs.some((long) => long.startsWith(name))
}
/** A short-option cluster (`-uo`, `-o./out`) that carries one of `letters`, or one of the long options (with or without `=`). */
const hasOption = (words: ShellWord[], letters: string, longs: string[] = []): boolean => words.some((w) =>
[...letters].some((l) => shortLetters(w.text).includes(l)) || isLong(w.text, longs))
/** A git option that writes, runs a pager or a driver, or points git elsewhere — abbreviated too, and `-O` in a cluster. */
const gitRefused = (w: ShellWord): boolean => isLong(w.text, GIT_REFUSED_LONG) || shortLetters(w.text).includes('O')
/** A path-like argument (or the value of a `--flag=value`) must resolve inside the project. */
function pathOk(w: ShellWord, ctx: Ctx): boolean {
// `.*` (and `.?`) can match `..` in some shells: a glob that starts a segment with a dot is not certain.
if (w.glob && w.text.split('/').some((segment) => segment.startsWith('.') && /[*?[]/.test(segment))) return false
let text = w.text
if (isFlag(w)) {
const eq = text.indexOf('=')
// `--file=/x`, or a short option with its value attached (`-f/x`): the value names a path when it looks like one.
text = eq >= 0 ? text.slice(eq + 1) : text.startsWith('--') ? '' : text.slice(1 + shortLetters(text).length)
if (!(text.startsWith('/') || text.startsWith('~') || text.split('/').includes('..'))) return true
}
if (!w.tilde && text.startsWith('~')) return false // a quoted `~`: a folder named `~`, or not — refused
return inProject(text, ctx.cwd, { home: ctx.home })
}
/** Every argument that is not a flag names a path in the project, after `skipOperands` leading operands. */
function argsInProject(words: ShellWord[], ctx: Ctx, skipOperands = 0): boolean {
let skipped = 0
for (const w of words) {
if (!isFlag(w) && skipped < skipOperands) { skipped++; continue }
if (!pathOk(w, ctx)) return false
}
return true
}
/** `npx vitest …`: a test tool named first, no launcher flag (`-y`, `-p` install and run a package). */
function allowedTool(argv: string[], words: ShellWord[], ctx: Ctx): boolean {
const [tool, ...rest] = argv
if (!tool) return false
if (tool === 'playwright') return rest[0] === 'test' && argsInProject(words.slice(2), ctx)
return TEST_TOOLS.has(tool) && argsInProject(words.slice(1), ctx)
}
/** One simple command, already free of expansions: is it a read, a test, a build, a linter or a formatter? */
function allowedSimple(cmd: ShellCommand, ctx: Ctx): boolean {
const [head, ...args] = cmd.words
// `X=1 npm test`: an environment prefix changes what the command does (NODE_OPTIONS, GIT_*, LD_*).
if (!head || head.assignment) return false
const name = head.text
if (head.quoted || head.glob || head.tilde || (name.includes('/') && name !== './gradlew')) return false
const argv = args.map((w) => w.text)
const noGlobs = !args.some((w) => w.glob)
const patternFirst = (words: ShellWord[]): number => hasOption(words, 'ef', ['--regexp', '--file']) ? 0 : 1
if (TEXT.has(name)) return true
if (READS.has(name)) return argsInProject(args, ctx, PATTERN_FIRST.has(name) ? patternFirst(args) : 0)
switch (name) {
case 'date':
return !hasOption(args, 's', ['--set'])
case 'sort':
// --files0-from reads the names of the files to print from a file: a path this cannot check.
return noGlobs && !hasOption(args, 'oT', ['--output', '--compress-program', '--temporary-directory', '--files0-from']) && argsInProject(args, ctx)
case 'uniq':
return noGlobs && args.filter((w) => !isFlag(w)).length <= 1 && argsInProject(args, ctx)
case 'tree':
return noGlobs && !hasOption(args, 'oR', ['--output']) && argsInProject(args.filter((_w, i) => !['-I', '-P'].includes(args[i - 1]?.text ?? '')), ctx)
case 'file':
return !hasOption(args, 'C', ['--compile']) && argsInProject(args, ctx)
case 'rg':
return noGlobs && !args.some((w) => /^--pre(-glob)?(=|$)/.test(w.text)) && argsInProject(args, ctx, patternFirst(args))
case 'find':
if (!noGlobs || args.some((w) => FIND_ACTIONS.has(w.text))) return false
return argsInProject(args.filter((_w, i) => !FIND_VALUE_ARGS.has(args[i - 1]?.text ?? '')), ctx)
case 'sed': {
if (!noGlobs) return false
const flags = args.filter(isFlag).map((w) => w.text)
if (!flags.some((f) => ['-n', '--quiet', '--silent'].includes(f))) return false
if (flags.some((f) => !['-n', '-E', '-r', '--quiet', '--silent'].includes(f))) return false
const operands = args.filter((w) => !isFlag(w))
return operands.length >= 1 && SED_PRINT.test(operands[0]!.text) && argsInProject(operands.slice(1), ctx)
}
case 'cd':
return args.length === 1 && argv[0] !== '-' && pathOk(args[0]!, ctx)
case 'git': {
if (!noGlobs) return false
let rest = args
while (rest[0]?.text === '--no-pager') rest = rest.slice(1)
const sub = rest[0]?.text ?? ''
const tail = rest.slice(1)
if (tail.some(gitRefused)) return false
if (GIT_READS.has(sub)) return argsInProject(tail, ctx, sub === 'grep' ? patternFirst(tail) : 0)
if (sub === 'reflog') return tail.length === 0 || (tail[0]!.text === 'show' && argsInProject(tail.slice(1), ctx))
if (sub === 'stash') return tail.length === 1 && tail[0]!.text === 'list'
if (sub === 'remote') return tail.length === 0 || (tail.length === 1 && ['-v', '--verbose'].includes(tail[0]!.text))
if (sub === 'branch') return tail.every((w) => GIT_BRANCH_LIST.has(w.text))
return false
}
case 'npm': case 'pnpm': case 'yarn': case 'bun': {
if (name === 'pnpm' && argv[0] === 'exec') return allowedTool(argv.slice(1), args.slice(1), ctx)
if (name === 'yarn' && argv[0] && TEST_TOOLS.has(argv[0])) return allowedTool(argv, args, ctx)
const at = argv[0] === 'run' ? 1 : 0
return !!argv[at] && SCRIPT.test(argv[at]!) && argsInProject(args.slice(at + 1), ctx)
}
case 'npx': case 'bunx':
return allowedTool(argv, args, ctx)
case 'python': case 'python3':
return argv[0] === '-m' && !!argv[1] && PY_MODULES.has(argv[1]) && argsInProject(args.slice(2), ctx)
case 'go':
if (args.some((w) => /^--?(exec|toolexec|vettool)(=|$)/.test(w.text))) return false
return ['test', 'build', 'vet', 'fmt'].includes(argv[0] ?? '') && argsInProject(args.slice(1), ctx)
case 'gofmt':
return argsInProject(args, ctx)
case 'cargo':
if (args.some((w) => /^--config(=|$)|^-Z/.test(w.text))) return false
return ['test', 'build', 'check', 'clippy', 'fmt', 'doc'].includes(argv[0] ?? '') && argsInProject(args.slice(1), ctx)
case 'make':
return args.every((w) => !isFlag(w) && MAKE_TARGETS.has(w.text))
case 'flutter': case 'dart':
return ['test', 'analyze', 'format', 'build'].includes(argv[0] ?? '') && argsInProject(args.slice(1), ctx)
case 'gradle': case './gradlew':
return /^(test|build|check|assemble\w*)$/.test(argv[0] ?? '') && argsInProject(args.slice(1), ctx)
case 'mvn':
return ['test', 'compile', 'verify', 'package'].includes(argv[0] ?? '') && argsInProject(args.slice(1), ctx)
case 'swift':
return ['test', 'build'].includes(argv[0] ?? '') && argsInProject(args.slice(1), ctx)
case 'rspec': case 'ctest': case 'rubocop':
return argsInProject(args, ctx)
case 'mix':
return ['test', 'format', 'compile'].includes(argv[0] ?? '') && argsInProject(args.slice(1), ctx)
case 'bundle':
if (argv[0] !== 'exec') return false
if (argv[1] === 'rspec' || argv[1] === 'rubocop') return argsInProject(args.slice(2), ctx)
return argv[1] === 'rake' && ['test', 'spec'].includes(argv[2] ?? '') && argsInProject(args.slice(3), ctx)
default:
return (TEST_TOOLS.has(name) || PY_TOOLS.has(name)) && argsInProject(args, ctx)
}
}
/** A redirection that only folds descriptors (`2>&1`), discards (`>/dev/null`) or reads a project file. */
function redirectOk(r: { op: string; fd: number | null; target: string }, ctx: Ctx): boolean {
if ((r.op === '>&' || r.op === '<&') && /^\d+$/.test(r.target)) return true
if (['>', '>>', '&>', '&>>', '>|'].includes(r.op)) return r.target === '/dev/null'
if (r.op === '<') return inProject(r.target, ctx.cwd, { home: ctx.home })
return false
}
/** The most of a command read at all: DIALOG_MAX, the most a dialog shows in full (protocol.ts, which re-exports
* from this file — so the number is kept here rather than imported). */
const COMMAND_MAX = 16_000
/**
* One command line is allowed when it reads with certainty and EVERY simple command in it is allowed.
* Paths are resolved against `opts.cwd`: without one, a command that names any path is not allowed.
*/
export function isAllowedCommand(command: string, opts: PathContext = {}): boolean {
// Longer than a dialog shows in full, it cannot be read before a key approves it — and every path in it
// costs a realpath: a bound on the time one command can take.
if (!command.trim() || command.length > COMMAND_MAX || isDenyClass(command)) return false
const parsed = parseShell(command)
if (!parsed.ok) return false
const ctx: Ctx = { cwd: opts.cwd ?? null, home: opts.home === undefined ? homedir() : opts.home }
return parsed.commands.every((cmd) => cmd.redirects.every((r) => redirectOk(r, ctx)) && allowedSimple(cmd, ctx))
}
// ── the structured tool call ────────────────────────────────────────────────────────────────────────
/** A tool call as the engine's transcript records it (Claude Code `tool_use`, Codex `function_call`). */
export interface ToolCall { name: string; input: unknown }
const record = (value: unknown): Record<string, unknown> | null =>
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
const parseMaybe = (value: unknown): unknown => {
if (typeof value !== 'string') return value
try { return JSON.parse(value) } catch { return value }
}
const shellQuote = (word: string): string => /^[A-Za-z0-9_./:=@%+,-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`
const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Read'])
const SEARCH_TOOLS = new Set(['Glob', 'Grep', 'LS'])
const SHELL_TOOLS = new Set(['Bash', 'shell', 'exec_command', 'local_shell'])
/** The shell command a tool call runs, exactly, or null when it runs none (or one this cannot read). */
export function toolCommand(tool: ToolCall): string | null {
if (!SHELL_TOOLS.has(tool.name)) return null
const input = record(parseMaybe(tool.input))
if (!input) return null
if (typeof input.command === 'string') return input.command
if (typeof input.cmd === 'string') return input.cmd
const argv = Array.isArray(input.command) && input.command.every((a) => typeof a === 'string') ? input.command as string[] : null
if (!argv?.length) return null
// Codex: ["bash", "-lc", "<script>"] runs the script; anything else is an argv, quoted back into a line.
if (argv.length === 3 && /^(\/bin\/|\/usr\/bin\/)?(ba|z)?sh$/.test(argv[0]!) && /^-l?c$/.test(argv[1]!)) return argv[2]!
return argv.map(shellQuote).join(' ')
}
/** The file a read/edit/search tool call names; null for a search with no path (the project itself). */
function toolPath(tool: ToolCall): string | null | undefined {
const input = record(parseMaybe(tool.input))
if (!input) return undefined
for (const key of ['file_path', 'notebook_path', 'path']) if (typeof input[key] === 'string') return input[key] as string
return null
}
/** Whether a `[y]` may approve this exact tool call. Anything unrecognised is not. */
export function isAllowToolCall(tool: ToolCall, opts: PathContext = {}): boolean {
if (SHELL_TOOLS.has(tool.name)) {
const command = toolCommand(tool)
return command !== null && isAllowedCommand(command, opts)
}
const path = toolPath(tool)
if (EDIT_TOOLS.has(tool.name)) return typeof path === 'string' && inProject(path, opts.cwd, { home: opts.home })
if (SEARCH_TOOLS.has(tool.name)) return path === null || (typeof path === 'string' && inProject(path, opts.cwd, { home: opts.home }))
return false
}
const squash = (text: string): string => text.replace(/\s+/g, '')
const FILE_HEADER = /^(edit|write|create|update|read|view)( file)?$/i
const SEARCH_HEADER = /^(glob|grep|search|list( files)?|ls)$/i
const BASH_HEADER = /^(bash( command)?|run( command)?|shell( command)?|execute( shell)?( command)?)$/i
const EDIT_QUESTION = /make this edit to (.+?)\?\s*$/i
/** An option row as painted (`❯ 1. Yes`), or a key hint under them. */
const OPTION_ROW = /^([>›❯*]\s*)?\d+[.)]\s|^(esc|press|enter|tab)\b/i
/** The lines under a header, to the first blank line after them. */
function blockUnder(lines: string[], headerAt: number): string[] {
const block: string[] = []
for (const line of lines.slice(headerAt + 1)) {
if (!line) { if (block.length) break; continue }
block.push(line)
}
return block
}
/** The `$ ` command line (Codex), with the lines under it up to a blank one; null when there is not exactly one. */
function dollarBlock(lines: string[]): { at: number; lines: string[] } | null {
const starts = lines.map((line, at) => ({ line, at })).filter(({ line }) => line.startsWith('$ '))
if (starts.length !== 1) return null
const at = starts[0]!.at
const out = [lines[at]!.slice(2)]
for (const line of lines.slice(at + 1)) { if (!line) break; out.push(line) }
return { at, lines: out }
}
/**
* The ONE open tool call this dialog paints, or null (none, or more than one could be). Strict: a shell
* call only under a shell header (or Codex's `$ `), whose painted lines are exactly its command (and its
* description); a file call only under a file header that names exactly its file.
*/
export function matchToolCall(dialog: string, tools: readonly ToolCall[], cwd?: string | null): ToolCall | null {
const lines = dialog.split('\n').map((line) => line.trim())
const headerAt = lines.findIndex((line) => FILE_HEADER.test(line) || BASH_HEADER.test(line) || SEARCH_HEADER.test(line))
const header = headerAt >= 0 ? lines[headerAt]! : ''
const dollar = dollarBlock(lines)
const hits = tools.filter((tool) => {
if (SHELL_TOOLS.has(tool.name)) {
const command = toolCommand(tool)
if (!command || !squash(command)) return false
const input = record(parseMaybe(tool.input))
const description = typeof input?.description === 'string' ? squash(input.description) : ''
if (dollar) return squash(dollar.lines.join('')) === squash(command)
if (!BASH_HEADER.test(header)) return false
const painted = squash(blockUnder(lines, headerAt).join(''))
return painted === squash(command) || (!!description && painted === squash(command) + description)
}
if (!FILE_HEADER.test(header) && !SEARCH_HEADER.test(header)) return false
const path = toolPath(tool)
if (typeof path !== 'string' || !path) return false
const named = paintedPath(blockUnder(lines, headerAt)[0] ?? '')
const shown = cwd && isAbsolute(path) ? relative(cwd, path) : path
return named === shown || named === path
})
return hits.length === 1 ? hits[0]! : null
}
// ── allow-class dialogs ─────────────────────────────────────────────────────────────────────────────
export interface AllowOptions extends PathContext {
permission: boolean
/** The engine's open tool calls for this harness, when its transcript has them. */
tools?: readonly ToolCall[]
}
/**
* Whether a `[y]` may approve this dialog. `dialog` is the whole dialog, line by line, as painted.
* Only a permission prompt can be allow-class; anything unrecognised, or not read with certainty, is not.
*/
export function isAllowClass(dialog: string, opts: AllowOptions): boolean {
if (!opts.permission || !dialog.trim() || isDenyClass(dialog)) return false
const ctx: PathContext = { cwd: opts.cwd, home: opts.home }
const lines = dialog.split('\n').map((line) => line.trim())
// Claude's edit prompt: the header names the file, the preview follows, then ONE question naming the
// same file, then only the options. A second "make this edit" line, or one with more than options after
// it (a line the preview's text painted), is file content, not the dialog: no [y].
const edits = lines.map((line, at) => ({ at, match: EDIT_QUESTION.exec(line) })).filter((e) => e.match)
if (edits.length > 1) return false
if (edits.length === 1) {
const { at, match } = edits[0]!
if (lines.slice(at + 1).some((line) => line && !OPTION_ROW.test(line))) return false
const file = paintedPath(match![1]!)
const headerAt = lines.findIndex((line) => FILE_HEADER.test(line))
if (headerAt < 0 || headerAt > at || paintedPath(blockUnder(lines, headerAt)[0] ?? '') !== file) return false
}
// The exact call, when the transcript has exactly one that this dialog paints.
const tool = opts.tools?.length ? matchToolCall(dialog, opts.tools, opts.cwd) : null
if (tool) return isAllowToolCall(tool, ctx)
if (edits.length === 1) return inProject(paintedPath(edits[0]!.match![1]!), opts.cwd, { home: opts.home })
// Codex (and anything that prints the command with a `$ ` prompt): exactly ONE command line, standing
// alone. A line right under it could be the command continuing (or wrapping): not read with certainty.
if (lines.some((line) => line.startsWith('$ '))) {
const dollar = dollarBlock(lines)
return !!dollar && dollar.lines.length === 1 && isAllowedCommand(dollar.lines[0]!, ctx)
}
const headerAt = lines.findIndex((line) => FILE_HEADER.test(line) || SEARCH_HEADER.test(line) || BASH_HEADER.test(line))
if (headerAt < 0) return false
const header = lines[headerAt]!
const block = blockUnder(lines, headerAt)
if (!block.length) return SEARCH_HEADER.test(header)
if (FILE_HEADER.test(header)) return block.length === 1 && inProject(paintedPath(block[0]!), opts.cwd, { home: opts.home })
if (SEARCH_HEADER.test(header)) {
return block.every((line) => !/(^|\s)(\/|~|\.\.)/.test(line) || inProject(paintedPath(line.split(/\s+/).pop()!), opts.cwd, { home: opts.home }))
}
// Bash, painted: the command's lines, then (usually) a description line — which a line of the command can
// look exactly like. Without the transcript's tool call, only a block of ONE line is read with certainty.
return block.length === 1 && isAllowedCommand(block[0]!, ctx)
}