diff --git a/.claude/skills/wizard-development/references/ARCHITECTURE.md b/.claude/skills/wizard-development/references/ARCHITECTURE.md index bc05ac3c0..3f4156ad5 100644 --- a/.claude/skills/wizard-development/references/ARCHITECTURE.md +++ b/.claude/skills/wizard-development/references/ARCHITECTURE.md @@ -114,6 +114,10 @@ mechanism. - [agent-interface.ts](../../../../src/agent/agent-interface.ts) configures the Anthropic SDK's tool permissions, sandbox, and gateway transport. +- [bash-fence.ts](../../../../src/agent/bash-fence.ts) is the shared bash + allowlist behind `wizardCanUseTool`, including the one `rm` rule: named files + inside the project root. In effect it gates Pi, since the Anthropic SDK + pre-allows Bash under its OS sandbox. - [yara-hooks.ts](../../../../src/agent/yara-hooks.ts) adapts warlock scans to SDK tool hooks. - [Pi security](../../../../src/agent/runner/harness/pi/security.ts) adapts diff --git a/package.json b/package.json index e4b2d9076..b14d88c2e 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "jsonc-parser": "^3.3.1", "lodash": "^4.17.21", "magicast": "^0.2.10", + "minimatch": "^10.2.5", "nanostores": "^1.1.1", "opn": "^5.4.0", "pi-mcp-adapter": "~2.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aad16369..f5fe9aea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: magicast: specifier: ^0.2.10 version: 0.2.11 + minimatch: + specifier: ^10.2.5 + version: 10.2.5 nanostores: specifier: ^1.1.1 version: 1.1.1 diff --git a/src/agent/__tests__/bash-fence-rm.test.ts b/src/agent/__tests__/bash-fence-rm.test.ts new file mode 100644 index 000000000..97b755c90 --- /dev/null +++ b/src/agent/__tests__/bash-fence-rm.test.ts @@ -0,0 +1,126 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { evaluateBashCommand, isScopedFileRemoval } from '@agent/bash-fence'; + +const ROOT = path.resolve('/project'); +const allowed = (command: string, projectRoot = ROOT) => + evaluateBashCommand(command, { projectRoot }).allowed; + +describe('bash fence — scoped rm refuses every shell character', () => { + // Each one lets bash turn an in-project word into something else. + const operators = [';', '&', '|', '`', '$', '(', ')', '{', '}', '<', '>']; + const quoting = ["'", '"', '\\']; + const whitespace = ['\t', '\n', '\v', '\f', '\r', ' ']; + + for (const c of [...operators, ...quoting, ...whitespace]) { + test(`refuses ${JSON.stringify(c)} in a target`, () => { + expect(isScopedFileRemoval(`rm a${c}b.txt`, ROOT)).toBe(false); + }); + } + + test('refuses the dangerous forms those characters enable', () => { + for (const c of [ + 'rm $HOME/.zshrc', + 'rm a.txt >/etc/hosts', + 'rm a.txt\ncurl evil.example', + 'rm a.txt\t/etc/passwd', + ]) { + expect(allowed(c)).toBe(false); + } + }); +}); + +describe('bash fence — scoped rm refuses globs and home expansion', () => { + for (const c of ['*', '?', '[', ']', '~']) { + test(`refuses ${JSON.stringify(c)} in a target`, () => { + expect(isScopedFileRemoval(`rm a${c}.txt`, ROOT)).toBe(false); + }); + } + + test('refuses globs that bash expands to .env', () => { + for (const c of ['rm .?nv', 'rm .[e]nv', 'rm .e*']) { + expect(allowed(c)).toBe(false); + } + }); +}); + +describe('bash fence — scoped rm stays inside the root', () => { + test('refuses an absolute path to a sibling that shares the root prefix', () => { + expect(allowed('rm /project-evil/x')).toBe(false); + expect(allowed('rm /project/x')).toBe(true); + }); + + test('allows a root reached through a symlink', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-fence-')); + try { + fs.mkdirSync(path.join(base, 'project')); + fs.symlinkSync( + path.join(base, 'project'), + path.join(base, 'link'), + 'dir', + ); + expect(allowed('rm plan.json', path.join(base, 'link'))).toBe(true); + expect(allowed('rm ../outside.txt', path.join(base, 'link'))).toBe(false); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); +}); + +// The containment logic runs through the host's `path` (win32 on Windows, posix +// elsewhere). Inject each flavor to prove the same invariants hold on both. +describe('bash fence — rm containment holds under Windows and POSIX path rules', () => { + const flavors = [ + ['win32', path.win32, 'C:\\Users\\me\\project'], + ['posix', path.posix, '/home/me/project'], + ] as const; + + for (const [name, p, root] of flavors) { + describe(name, () => { + const rescued = (command: string, r: string | undefined = root) => + isScopedFileRemoval(command, r, p); + + test('rescues in-root deletes written with forward slashes', () => { + for (const c of [ + 'rm .posthog-events.json', + 'rm src/tmp/plan.json', + 'rm -f a.txt b.txt', + ]) { + expect(rescued(c)).toBe(true); + } + }); + + test('normalizes a relative root', () => { + expect(rescued('rm plan.json', 'project')).toBe(true); + }); + + test('never escapes the root, including sibling-prefix dirs', () => { + for (const c of [ + 'rm ../outside', + 'rm src/../../outside', + 'rm ../project-evil/x', + 'rm /c/Windows/system32/x', + ]) { + expect(rescued(c)).toBe(false); + } + }); + + test('rejects backslash paths (pi runs POSIX bash, never cmd.exe)', () => { + expect(rescued('rm src\\tmp\\plan.json')).toBe(false); + }); + + test('still rejects quotes, globs, .env, and recursion', () => { + for (const c of [ + 'rm "a.txt"', + "rm '/etc/passwd'", + 'rm *.json', + 'rm config/.env.local', + 'rm -rf src', + ]) { + expect(rescued(c)).toBe(false); + } + }); + }); + } +}); diff --git a/src/agent/__tests__/wizard-can-use-tool.test.ts b/src/agent/__tests__/wizard-can-use-tool.test.ts index 3d42f954f..e1806136c 100644 --- a/src/agent/__tests__/wizard-can-use-tool.test.ts +++ b/src/agent/__tests__/wizard-can-use-tool.test.ts @@ -1,4 +1,8 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { wizardCanUseTool } from '@agent/agent-interface'; +import { analytics } from '@utils/analytics'; vi.mock('@utils/analytics', () => ({ analytics: { @@ -7,6 +11,183 @@ vi.mock('@utils/analytics', () => ({ })); vi.mock('@utils/debug'); +describe('wizardCanUseTool — scoped rm inside the project', () => { + const capture = vi.mocked(analytics.wizardCapture); + let base: string; + let root: string; + + beforeAll(() => { + // Under os.tmpdir() on purpose: on macOS it is itself a symlink. + base = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-rm-')); + root = path.join(base, 'project'); + fs.mkdirSync(root); + fs.mkdirSync(path.join(base, 'outside')); + fs.symlinkSync(path.join(base, 'outside'), path.join(root, 'docs'), 'dir'); + }); + afterAll(() => fs.rmSync(base, { recursive: true, force: true })); + beforeEach(() => capture.mockClear()); + + const decide = (command: string) => + wizardCanUseTool('Bash', { command }, { workingDirectory: root }).behavior; + + it('allows a plain rm of a project file and records no denial', () => { + expect(decide('rm -f .posthog-audit-checks.json')).toBe('allow'); + expect(capture).not.toHaveBeenCalled(); + }); + + it('denies rm, and records it, when no project root is known', () => { + const result = wizardCanUseTool('Bash', { command: 'rm plan.json' }); + expect(result.behavior === 'deny' && result.message).toMatch( + /rm is not available/, + ); + expect(capture).toHaveBeenCalledWith('bash denied', { + reason: 'not in allowlist', + command: 'rm plan.json', + }); + }); + + it('denies rm through a symlinked directory that leaves the project', () => { + expect(decide('rm docs/secret.txt')).toBe('deny'); + // bash follows `docs` before `..`, so this lands beside the project. + expect(decide('rm docs/../project-sibling.txt')).toBe('deny'); + // bash splits words on space, tab, and newline only, so this is one target. + expect(decide('rm -f docs/\vsecret.txt')).toBe('deny'); + // ...and this is two, the second one outside the project. + expect(decide('rm a.txt\t/etc/passwd')).toBe('deny'); + }); + + it('allows removing a symlink that sits in the project, which deletes only the link', () => { + expect(decide('rm docs')).toBe('allow'); + }); + + it('tells the agent the rm rule when an rm is denied', () => { + const result = wizardCanUseTool( + 'Bash', + { command: 'rm -rf node_modules' }, + { workingDirectory: root }, + ); + expect(result.behavior === 'deny' && result.message).toMatch( + /rm \[-f\] /, + ); + }); + + it('denies .env targets in any case or escaped form', () => { + for (const c of [ + 'rm .env', + 'rm .ENV', + 'rm config/.Env.local', + 'rm \\.env', + ]) { + expect(decide(c)).toBe('deny'); + } + }); +}); + +describe('wizardCanUseTool — .env guard ignores case', () => { + it('denies Read, Write, and Edit of .env in any case', () => { + for (const tool of ['Read', 'Write', 'Edit']) { + for (const file_path of ['.ENV', 'app/.Env.local']) { + expect(wizardCanUseTool(tool, { file_path }).behavior).toBe('deny'); + } + } + }); + + it('still allows an env template in any case', () => { + expect( + wizardCanUseTool('Write', { file_path: '.ENV.EXAMPLE' }).behavior, + ).toBe('allow'); + }); + + it('denies Grep aimed at .env in any case', () => { + expect(wizardCanUseTool('Grep', { path: '.ENV' }).behavior).toBe('deny'); + }); + + it('denies a Grep glob that can pull .env files into the search', () => { + // ripgrep lets a --glob override .gitignore, so these reach a real .env. + for (const glob of ['.env*', '**/.ENV', '*', '**/*', '{.env,x}', '?env']) { + expect(wizardCanUseTool('Grep', { path: '.', glob }).behavior).toBe( + 'deny', + ); + } + expect( + wizardCanUseTool('Grep', { path: '.', glob: '**/*.ts' }).behavior, + ).toBe('allow'); + }); + + it('denies a Grep glob that names a specific env file at any depth', () => { + for (const glob of [ + 'apps/api/.env.local', + '.env.production', + '.env.prod*', + '*.production', + '.env.development.local', + '.envrc', + '{src,apps/api}/.env.local', + ]) { + expect(wizardCanUseTool('Grep', { path: '.', glob }).behavior).toBe( + 'deny', + ); + } + }); + + it('allows a Grep exclude glob and globs that cannot match .env*', () => { + for (const glob of ['!*.min.js', '!.env*', 'src/**/*.tsx', '.gitignore']) { + expect(wizardCanUseTool('Grep', { path: '.', glob }).behavior).toBe( + 'allow', + ); + } + }); +}); + +describe('wizardCanUseTool — Grep globs checked against the env files that exist', () => { + let root: string; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-grep-env-')); + fs.mkdirSync(path.join(root, 'apps', 'api'), { recursive: true }); + fs.writeFileSync(path.join(root, '.env.foo'), 'A=1\n'); + fs.writeFileSync(path.join(root, 'apps', 'api', '.env.stagingx'), 'B=1\n'); + fs.writeFileSync(path.join(root, 'app.ts'), 'export {}\n'); + }); + afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + + const grep = (glob: string, searchPath?: string) => + wizardCanUseTool( + 'Grep', + { glob, ...(searchPath ? { path: searchPath } : {}) }, + { workingDirectory: root }, + ).behavior; + + it('denies a leading-wildcard glob that selects an env file in the project', () => { + expect(grep('*.foo')).toBe('deny'); + expect(grep('{*.foo,x}')).toBe('deny'); + expect(grep('*.stagingx')).toBe('deny'); + }); + + it('denies a glob by relative path to an env file under the Grep path', () => { + expect(grep('api/*.stagingx', 'apps')).toBe('deny'); + expect(grep('apps/*/.env.stagingx')).toBe('deny'); + }); + + it('allows a leading-wildcard glob that no env file matches', () => { + expect(grep('**/*.ts')).toBe('allow'); + expect(grep('*.bar')).toBe('allow'); + }); + + it('allows a leading-wildcard glob when the env file is outside the Grep path', () => { + expect(grep('*.foo', 'apps/api')).toBe('allow'); + }); + + it('keeps denying a conventional env name that is not on disk', () => { + expect(grep('*.local')).toBe('deny'); + expect(grep('apps/api/.env.local')).toBe('deny'); + }); + + it('allows an exclude glob', () => { + expect(grep('!*.min.js')).toBe('allow'); + }); +}); + describe('wizardCanUseTool — wizard_ask pending guard', () => { for (const tool of ['Write', 'Edit'] as const) { it(`denies ${tool} while a wizard_ask overlay is pending`, () => { diff --git a/src/agent/agent-interface.ts b/src/agent/agent-interface.ts index 5b9e8fb13..a3f317de1 100644 --- a/src/agent/agent-interface.ts +++ b/src/agent/agent-interface.ts @@ -22,6 +22,8 @@ import { import type { WizardRunOptions } from '@utils/types'; import { analytics } from '@utils/analytics'; import { + globCanSelectEnvFile, + isEnvFileNameAnyCase, isTemplateEnvFileName, TEMPLATE_ENV_FILE_NAMES, } from '@utils/env-scan'; @@ -452,6 +454,8 @@ export function wizardCanUseTool( disallowedTools?: readonly string[]; /** A debug run's line for each Bash decision. */ onDebug?: (line: string) => void; + /** Project root; the bash fence allows a plain `rm` of files inside it. */ + workingDirectory?: string; } = {}, ): | { behavior: 'allow'; updatedInput: Record } @@ -494,7 +498,10 @@ export function wizardCanUseTool( if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') { const filePath = typeof input.file_path === 'string' ? input.file_path : ''; const basename = path.basename(filePath); - if (basename.startsWith('.env') && !isTemplateEnvFileName(basename)) { + if ( + isEnvFileNameAnyCase(basename) && + !isTemplateEnvFileName(basename.toLowerCase()) + ) { logToFile(`Denying ${toolName} on env file: ${filePath}`); return { behavior: 'deny', @@ -504,12 +511,22 @@ export function wizardCanUseTool( return { behavior: 'allow', updatedInput: input }; } - // Block Grep when it directly targets a .env file. - // Note: ripgrep skips dotfiles (like .env*) by default during directory traversal, - // so broad searches like `Grep { path: "." }` are already safe. + // Block Grep when it targets a .env file, by path or by a glob that selects + // one; ripgrep lets a glob override .gitignore. if (toolName === 'Grep') { const grepPath = typeof input.path === 'string' ? input.path : ''; - if (grepPath && path.basename(grepPath).startsWith('.env')) { + const glob = typeof input.glob === 'string' ? input.glob : ''; + const searchRoot = grepPath + ? path.resolve(context.workingDirectory ?? '.', grepPath) + : context.workingDirectory; + if (glob && globCanSelectEnvFile(glob, searchRoot)) { + logToFile(`Denying Grep glob that selects env files: ${glob}`); + return { + behavior: 'deny', + message: `Grep with glob ${glob} can search .env files and is not allowed. Narrow the glob, or use the wizard-tools MCP server (check_env_keys) to check environment variables.`, + }; + } + if (grepPath && isEnvFileNameAnyCase(path.basename(grepPath))) { logToFile(`Denying Grep on env file: ${grepPath}`); return { behavior: 'deny', @@ -530,7 +547,9 @@ export function wizardCanUseTool( typeof input.command === 'string' ? input.command : '' ).trim(); - const decision = evaluateBashCommand(command); + const decision = evaluateBashCommand(command, { + projectRoot: context.workingDirectory, + }); if (decision.allowed) { logToFile(`Allowing bash command: ${command}`); context.onDebug?.(`Allowing bash command: ${command}`); diff --git a/src/agent/bash-fence.ts b/src/agent/bash-fence.ts index 1af7c1f0e..2d8833ec8 100644 --- a/src/agent/bash-fence.ts +++ b/src/agent/bash-fence.ts @@ -12,13 +12,23 @@ * registry actions (publish/push/deploy), arbitrary-package execution * (`npx ` downloads and runs it), and shell injection. Matching is * token-exact per manager — keyword prefixes admitted `npm publish` via `pub`. + * `rm` is allowed only as a plain delete of named files inside the project + * root. */ +import fs from 'fs'; +import path from 'path'; import { LINTING_TOOLS } from './safe-tools'; +import { isEnvFileNameAnyCase } from '@utils/env-scan'; export type BashFenceDecision = | { allowed: true } | { allowed: false; message: string; analyticsReason: string }; +export type BashFenceOptions = { + /** Project root. A plain `rm` of files inside it is allowed; absent, every `rm` is denied. */ + projectRoot?: string; +}; + const NODE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']); const GRADLE_MANAGERS = new Set(['gradle', 'gradlew', './gradlew']); const MAVEN_MANAGERS = new Set(['mvn', 'mvnw', './mvnw']); @@ -143,6 +153,11 @@ const XCODEBUILD_DENIED_ACTIONS = new Set(['test', 'test-without-building']); const DANGEROUS_OPERATORS = /[;`$()]/; +// Stricter than DANGEROUS_OPERATORS: a plain `rm` also refuses quotes, +// escapes, braces, redirects, and any whitespace but a space, so the targets +// checked here are exactly the words bash passes to rm. +const RM_SHELL_OPERATORS = /[;&|`$(){}<>'"\\]|[^\S ]/; + const ALLOWED_TOOLS_SUMMARY = 'Allowed: npm/pnpm/yarn/bun (install|i|ci|add|remove|uninstall|update|view, run ), ' + 'npx , pip/pip3/poetry/pipenv/uv/pdm/conda (install/add/remove/...), ' + @@ -308,7 +323,10 @@ function xcodebuildDecision( } /** Grammar decision for a single operator-free, pipe-free command. */ -function commandDecision(command: string): BashFenceDecision { +function commandDecision( + command: string, + options: BashFenceOptions, +): BashFenceDecision { const parts = command.split(/\s+/).filter(Boolean); const raw = parts[0]; if (!raw) return denyCommand(command, ALLOWED_TOOLS_SUMMARY); @@ -324,6 +342,14 @@ function commandDecision(command: string): BashFenceDecision { if (GRADLE_MANAGERS.has(bin)) return gradleDecision(parts, command); if (MAVEN_MANAGERS.has(bin)) return mavenDecision(parts, command); if (bin === 'xcodebuild') return xcodebuildDecision(parts, command); + if (bin === 'rm') { + return denyCommand( + command, + options.projectRoot + ? 'rm may only delete named files inside the project, as a plain rm [-f] with nothing else on the line: no recursion, globs, quotes, `..`, redirects, pipes, or .env files.' + : 'rm is not available in this session.', + ); + } if (bin === 'python' || bin === 'python3') { // Django's system check. if (parts[1] === 'manage.py' && parts[2] === 'check') { @@ -443,6 +469,64 @@ function commandDecision(command: string): BashFenceDecision { ); } +/** True when a target names a non-env file strictly inside the project root, with no flag, glob, or `..`. */ +function isDeletableProjectFile( + target: string, + root: string, + p: typeof path, +): boolean { + if (target.startsWith('-')) return false; // a flag, not a file + if (/[*?[\]~]/.test(target)) return false; // glob / home expansion + if (target.split('/').includes('..')) return false; // bash follows a symlink before `..` + if (isEnvFileNameAnyCase(p.basename(target))) return false; // secrets + + // Compare real paths, so a symlinked directory on the way can't lead out. + const resolved = p.resolve(root, target); + const realTarget = p.join( + realPathOf(p.dirname(resolved), p), + p.basename(resolved), + ); + return realTarget.startsWith(realPathOf(root, p) + p.sep); +} + +/** `target` with its deepest existing ancestor's symlinks resolved; a missing tail stays as written. */ +function realPathOf(target: string, p: typeof path): string { + const missing: string[] = []; + for (let dir = target; ; dir = p.dirname(dir)) { + try { + return p.join(fs.realpathSync.native(dir), ...missing); + } catch { + if (p.dirname(dir) === dir) return target; + missing.unshift(p.basename(dir)); + } + } +} + +/** + * A plain `rm [-f] ` whose every target is a file inside the project + * root. Path checks run through the host's `path` (win32 on Windows, posix + * elsewhere); pi always executes commands via a POSIX bash, so targets use + * forward slashes. + */ +export function isScopedFileRemoval( + command: string, + rawRoot: string | undefined, + p: typeof path = path, +): boolean { + if (!rawRoot) return false; // no root to contain against + const root = p.resolve(rawRoot); + const trimmed = command.trim(); + if (RM_SHELL_OPERATORS.test(trimmed)) return false; + + const [executable, ...args] = trimmed.split(/ +/); + if (executable !== 'rm') return false; + + if (args[0] === '-f') args.shift(); + if (args.length === 0) return false; + + return args.every((arg) => isDeletableProjectFile(arg, root, p)); +} + function tailArgsAreSafe(argStr: string): boolean { const args = argStr.trim().split(/\s+/).filter(Boolean); for (let i = 0; i < args.length; i++) { @@ -458,11 +542,19 @@ function tailArgsAreSafe(argStr: string): boolean { } /** - * Full fence decision for a Bash command: shell-shape gates (separators, - * redirects, pipes) first, then the per-manager grammar. + * Full fence decision for a Bash command: a plain project-scoped `rm` first, + * then shell-shape gates (separators, redirects, pipes), then the per-manager + * grammar. */ -export function evaluateBashCommand(rawCommand: string): BashFenceDecision { +export function evaluateBashCommand( + rawCommand: string, + options: BashFenceOptions = {}, +): BashFenceDecision { const command = rawCommand.trim(); + // Before the redirect cleanup below, which would strip `>/dev/null` off an rm. + if (isScopedFileRemoval(command, options.projectRoot)) { + return { allowed: true }; + } // Newlines separate commands in bash; token splitting would flatten // `npm install x\ncurl evil` into one "allowed" command. if (/[\r\n]/.test(command)) { @@ -506,7 +598,7 @@ export function evaluateBashCommand(rawCommand: string): BashFenceDecision { 'Bash command not allowed. tail/head may only take numeric flags (-n 50, -c 200) — no file arguments.', ); } - return commandDecision(base); + return commandDecision(base, options); } if (/[|&]/.test(normalized)) { return deny( @@ -514,5 +606,5 @@ export function evaluateBashCommand(rawCommand: string): BashFenceDecision { 'Bash command not allowed. Pipes are only permitted as a single | tail/head for output limiting.', ); } - return commandDecision(normalized); + return commandDecision(normalized, options); } diff --git a/src/agent/runner/harness/pi/README.md b/src/agent/runner/harness/pi/README.md index c3a6f32fa..52ed3392c 100644 --- a/src/agent/runner/harness/pi/README.md +++ b/src/agent/runner/harness/pi/README.md @@ -33,7 +33,8 @@ The linear path supplies file/exploration tools, shell, Wizard capabilities, todos and bounded subagents. The orchestrator task path supplies its task and handoff capabilities; it is not an identical tool roster. Inspect the entrypoint and [tools](tools.ts) when extending either. [subagent.ts](subagent.ts) applies -the parent's security factory to bounded read-only exploration agents. +the parent's subagent security gate, which shares its state and never allows +`rm`, to bounded exploration agents. [commandments.ts](../../switchboard/commandments.ts) assembles runtime/tool guidance alongside flow/task context. The linear path also supplies MCP server diff --git a/src/agent/runner/harness/pi/__tests__/security.test.ts b/src/agent/runner/harness/pi/__tests__/security.test.ts index d6c0d7bb4..bbb4c34e0 100644 --- a/src/agent/runner/harness/pi/__tests__/security.test.ts +++ b/src/agent/runner/harness/pi/__tests__/security.test.ts @@ -5,7 +5,6 @@ import { scan, triageMatches, type ScanMatch } from '@posthog/warlock'; import { evaluateToolCall, createSecurityExtension, - isScopedFileRemoval, observeTransportLeak, overwriteShrinkReason, MAX_TOOL_CALLS, @@ -47,6 +46,8 @@ const injectionMatch: ScanMatch = { matchedStrings: ['ignore previous instructions'], }; +const PROJECT = path.resolve('/project'); + const block = async (toolName: string, input: Record) => (await evaluateToolCall(toolName, input)).block; @@ -87,6 +88,21 @@ describe('pi-security: blocked-action corpus (parity with the anthropic fence)', expect(await block('grep', { path: '.env' })).toBe(true); }); + test('checks the path pi opens, after it strips `@` and decodes file://', async () => { + expect(await block('read', { path: '@.env' })).toBe(true); + expect(await block('write', { path: '@.env', content: 'X=1' })).toBe(true); + expect(await block('edit', { path: '@config/.env.local', edits: [] })).toBe( + true, + ); + expect(await block('read', { path: 'file:///project/%2Eenv' })).toBe(true); + expect(await block('grep', { path: '@.env' })).toBe(true); + }); + + test('blocks a grep glob that can pull .env files into the search', async () => { + expect(await block('grep', { path: '.', glob: '.env*' })).toBe(true); + expect(await block('grep', { path: '.', glob: '**/*.ts' })).toBe(false); + }); + test('allows .env example/template files — they document keys, hold no secrets', async () => { expect( await block('write', { path: '.env.example', content: 'KEY=' }), @@ -275,7 +291,9 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)', } test('blocks a denied call and counts it', async () => { - const { factory, state } = createSecurityExtension(); + const { factory, state } = createSecurityExtension({ + workingDirectory: PROJECT, + }); const { pi, handlers } = fakePi(); factory(pi); expect( @@ -296,9 +314,29 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)', ).toEqual({}); }); + test('the subagent gate refuses rm and shares the parent state', async () => { + const { factory, subagentFactory, state } = createSecurityExtension({ + workingDirectory: PROJECT, + }); + const parent = fakePi(); + const child = fakePi(); + factory(parent.pi); + subagentFactory(child.pi); + const rm = { toolName: 'bash', input: { command: 'rm plan.json' } }; + expect(await parent.handlers.tool_call(rm)).toEqual({}); + expect(await child.handlers.tool_call(rm)).toEqual({ + block: true, + reason: expect.any(String), + }); + expect(state.toolCalls).toBe(2); + expect(state.blockedCount).toBe(1); + }); + test('a scanner error on publish_handoff latches and ends the run', async () => { // Blocking alone would leave the agent rewording a report forever. - const { factory, state } = createSecurityExtension(); + const { factory, state } = createSecurityExtension({ + workingDirectory: PROJECT, + }); const { pi, handlers } = fakePi(); factory(pi); mockedScan.mockRejectedValueOnce(new Error('wasm boom')); @@ -312,7 +350,9 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)', }); test('a scanner error on a write blocks without ending the run', async () => { - const { factory, state } = createSecurityExtension(); + const { factory, state } = createSecurityExtension({ + workingDirectory: PROJECT, + }); const { pi, handlers } = fakePi(); factory(pi); mockedScan.mockRejectedValueOnce(new Error('wasm boom')); @@ -326,7 +366,9 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)', }); test('a post-scan violation latches and terminates all further calls', async () => { - const { factory, state } = createSecurityExtension(); + const { factory, state } = createSecurityExtension({ + workingDirectory: PROJECT, + }); const { pi, handlers } = fakePi(); factory(pi); // A read whose OUTPUT contains a prompt-injection override → post-scan latch. @@ -357,7 +399,9 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)', }); test('a non-critical, non-block post-scan match warns without terminating', async () => { - const { factory, state } = createSecurityExtension(); + const { factory, state } = createSecurityExtension({ + workingDirectory: PROJECT, + }); const { pi, handlers } = fakePi(); factory(pi); // piiMatch is severity: 'high', action: 'remediate' — below the terminate @@ -378,6 +422,7 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)', test('with a triage provider, a false_positive verdict unblocks the write', async () => { const { factory, state } = createSecurityExtension({ + workingDirectory: PROJECT, triageProvider: () => Promise.resolve('false_positive'), }); const { pi, handlers } = fakePi(); @@ -399,7 +444,9 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)', }); test('a scanner error on tool output latches (fail closed)', async () => { - const { factory, state } = createSecurityExtension(); + const { factory, state } = createSecurityExtension({ + workingDirectory: PROJECT, + }); const { pi, handlers } = fakePi(); factory(pi); mockedScan.mockRejectedValueOnce(new Error('wasm exploded')); @@ -411,7 +458,9 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)', }); test('runaway guard blocks past the cap', async () => { - const { factory, state } = createSecurityExtension(); + const { factory, state } = createSecurityExtension({ + workingDirectory: PROJECT, + }); const { pi, handlers } = fakePi(); factory(pi); for (let i = 0; i < MAX_TOOL_CALLS; i++) { @@ -500,7 +549,7 @@ describe('pi-security: repeat-block escalation (identical retries after a YARA b }; test('an identical YARA-blocked write escalates, then says report-and-move-on', async () => { - const { factory } = createSecurityExtension(); + const { factory } = createSecurityExtension({ workingDirectory: PROJECT }); const { pi, handlers } = fakePi(); factory(pi); @@ -525,7 +574,7 @@ describe('pi-security: repeat-block escalation (identical retries after a YARA b }); test('different blocked content is a fresh first attempt, not a repeat', async () => { - const { factory } = createSecurityExtension(); + const { factory } = createSecurityExtension({ workingDirectory: PROJECT }); const { pi, handlers } = fakePi(); factory(pi); @@ -545,7 +594,7 @@ describe('pi-security: repeat-block escalation (identical retries after a YARA b }); test('policy denies (non-YARA) never gain repeat-escalation text', async () => { - const { factory } = createSecurityExtension(); + const { factory } = createSecurityExtension({ workingDirectory: PROJECT }); const { pi, handlers } = fakePi(); factory(pi); @@ -558,11 +607,10 @@ describe('pi-security: repeat-block escalation (identical retries after a YARA b }); }); -// pi lets a plain `rm` of files INSIDE the project root through the allowlist -// (matching the anthropic arm, where bash is unrestricted and YARA is the real -// guard). Two invariants: it can delete project files, and it can never touch -// anything outside the root or smuggle a second command via a shell operator. -describe('pi-security: plain rm matches the anthropic arm', () => { +// The shared fence lets a plain `rm` of files INSIDE the project root through. +// Two invariants at the pi gate: it can delete project files, and it can never +// touch anything outside the root or smuggle a second command. +describe('pi-security: plain rm of project files', () => { const ROOT = path.resolve('/project'); const rmBlocked = async (command: string) => (await evaluateToolCall('bash', { command }, { workingDirectory: ROOT })) @@ -658,6 +706,24 @@ describe('pi-security: plain rm matches the anthropic arm', () => { ); }); + test('an allowed scoped rm is not captured as a denied bash command', async () => { + vi.mocked(analytics.wizardCapture).mockClear(); + expect(await rmBlocked('rm -f .posthog-audit-checks.json')).toBe(false); + expect(analytics.wizardCapture).not.toHaveBeenCalledWith( + 'bash denied', + expect.anything(), + ); + }); + + test('a scoped rm still obeys a program that disallows Bash', async () => { + const decision = await evaluateToolCall( + 'bash', + { command: 'rm plan.json' }, + { workingDirectory: ROOT, disallowedTools: ['Bash'] }, + ); + expect(decision.block).toBe(true); + }); + test('normalizes a relative workingDirectory', async () => { const relRoot = path.relative(process.cwd(), path.resolve('/project')); expect( @@ -672,63 +738,6 @@ describe('pi-security: plain rm matches the anthropic arm', () => { }); }); -// The containment logic runs through the host's `path` (win32 on Windows, posix -// elsewhere). Inject each flavor to prove the same invariants hold on both. -describe('pi-security: rm containment holds under Windows and POSIX path rules', () => { - const flavors = [ - ['win32', path.win32, 'C:\\Users\\me\\project'], - ['posix', path.posix, '/home/me/project'], - ] as const; - - for (const [name, p, root] of flavors) { - describe(name, () => { - const rescued = (command: string, r: string | undefined = root) => - isScopedFileRemoval(command, r, p); - - test('rescues in-root deletes written with forward slashes', () => { - for (const c of [ - 'rm .posthog-events.json', - 'rm src/tmp/plan.json', - 'rm -f a.txt b.txt', - ]) { - expect(rescued(c)).toBe(true); - } - }); - - test('normalizes a relative root', () => { - expect(rescued('rm plan.json', 'project')).toBe(true); - }); - - test('never escapes the root, including sibling-prefix dirs', () => { - for (const c of [ - 'rm ../outside', - 'rm src/../../outside', - 'rm ../project-evil/x', - 'rm /c/Windows/system32/x', - ]) { - expect(rescued(c)).toBe(false); - } - }); - - test('rejects backslash paths (pi runs POSIX bash, never cmd.exe)', () => { - expect(rescued('rm src\\tmp\\plan.json')).toBe(false); - }); - - test('still rejects quotes, globs, .env, and recursion', () => { - for (const c of [ - 'rm "a.txt"', - "rm '/etc/passwd'", - 'rm *.json', - 'rm config/.env.local', - 'rm -rf src', - ]) { - expect(rescued(c)).toBe(false); - } - }); - }); - } -}); - describe('pi-security: overwrite shrink guard (destructive whole-file rewrite)', () => { // ~960 non-whitespace chars — comfortably above OVERWRITE_MIN_CHARS. const big = 'const value = compute();\n'.repeat(40); @@ -763,6 +772,14 @@ describe('pi-security: overwrite shrink guard (destructive whole-file rewrite)', expect(gut.block).toBe(true); expect(gut.reason).toContain('targeted edits'); + // pi strips a leading `@`, so this overwrites existing.ts too. + const atGut = await evaluateToolCall( + 'write', + { path: '@existing.ts', content: 'const value = compute();' }, + { workingDirectory: dir }, + ); + expect(atGut.block).toBe(true); + const fresh = await evaluateToolCall( 'write', { path: 'brand-new.ts', content: 'const value = compute();' }, diff --git a/src/agent/runner/harness/pi/index.ts b/src/agent/runner/harness/pi/index.ts index 58f18f0cf..5d8a16e0c 100644 --- a/src/agent/runner/harness/pi/index.ts +++ b/src/agent/runner/harness/pi/index.ts @@ -546,7 +546,7 @@ export const piBackend: AgentHarness = { modelRegistry: registry, cwd: input.installDir, agentDir: getAgentDir(), - securityFactory: security.factory as (pi: unknown) => void, + securityFactory: security.subagentFactory, bashTool: scrubbedBash, sdk: { createAgentSession, DefaultResourceLoader, SessionManager }, }), @@ -842,9 +842,9 @@ export const piBackend: AgentHarness = { }); } - // The skill plans events into .posthog-events.json then asks to remove it - // on completion; pi's `rm` is fence-blocked, so the agent can't — clean it - // up host-side rather than leave a stale (often empty) artifact (#15). + // The skill plans events into .posthog-events.json then asks the agent to + // remove it on completion; clean it up host-side too, so a skipped step + // never leaves a stale (often empty) artifact (#15). try { const planFile = path.join(input.installDir, '.posthog-events.json'); if (!structured && fs.existsSync(planFile)) diff --git a/src/agent/runner/harness/pi/security.ts b/src/agent/runner/harness/pi/security.ts index b52e716d6..c4e4406a6 100644 --- a/src/agent/runner/harness/pi/security.ts +++ b/src/agent/runner/harness/pi/security.ts @@ -2,8 +2,9 @@ * Fail-closed security for the pi backend (#525). pi has no built-in * permission layer, so we attach an extension that intercepts every tool call * — built-in (bash/read/edit/write/grep) AND custom — through pi's `tool_call` - * hook and reuses the EXACT anthropic policy: `wizardCanUseTool` (the bash - * allowlist + .env fencing) plus the YARA pre-scan. A `tool_result` hook + * hook and reuses the shared tool policy: `wizardCanUseTool` (the bash fence, + * whose one `rm` rule is project-scoped, + .env fencing) plus the YARA + * pre-scan. A `tool_result` hook * post-scans output. Both fail closed: a scanner error blocks, and a critical * post-scan violation latches so every subsequent tool call is blocked and the * run terminates as a YARA violation. @@ -13,12 +14,13 @@ * harness. pi handlers are async (pi's ExtensionHandler accepts promises), so * the WASM scan awaits inline. * - * This is the one fence. Subagents run their own pi session with the SAME - * extension installed (see subagent.ts), so a child cannot escape it. + * This is the one fence. Subagents run their own pi session with its subagent + * gate installed (see subagent.ts): the same fence and state, with no `rm`. */ import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import type { LLMProvider, ScanMatch } from '@posthog/warlock'; import { wizardCanUseTool } from '../../../agent-interface'; import { @@ -129,6 +131,18 @@ export interface GateDecision { const str = (v: unknown): string => (typeof v === 'string' ? v : ''); +/** A pi tool path as pi opens it: its `resolveToCwd` strips a leading `@` and decodes `file://`. */ +function piToolPath(v: unknown): string { + const raw = str(v); + const p = raw.startsWith('@') ? raw.slice(1) : raw; + if (!p.startsWith('file://')) return p; + try { + return fileURLToPath(p); + } catch { + return p; // pi fails to open it too + } +} + // Shell metacharacters that chain, substitute, or redirect. A command carrying // any of these is more than one action, so we never treat it as a plain `rm`. const SHELL_OPERATORS = /[;&|`$(){}<>\n'"\\]/; @@ -218,7 +232,7 @@ async function overwriteShrinkBlock( input: Record, workingDirectory: string | undefined, ): Promise { - const target = str(input.path); + const target = piToolPath(input.path); if (!workingDirectory || !target) return undefined; let existing: string; try { @@ -245,13 +259,16 @@ function toClaudePolicyCall( case 'bash': return { name: 'Bash', input: { command: str(input.command) } }; case 'read': - return { name: 'Read', input: { file_path: input.path } }; + return { name: 'Read', input: { file_path: piToolPath(input.path) } }; case 'write': - return { name: 'Write', input: { file_path: input.path } }; + return { name: 'Write', input: { file_path: piToolPath(input.path) } }; case 'edit': - return { name: 'Edit', input: { file_path: input.path } }; + return { name: 'Edit', input: { file_path: piToolPath(input.path) } }; case 'grep': - return { name: 'Grep', input: { path: input.path } }; + return { + name: 'Grep', + input: { path: piToolPath(input.path), glob: input.glob }, + }; default: // Custom tools (load_skill_menu, set_env_values, dispatch_agent, …) + // find/ls: no path/command, policy allows (their own handlers are fenced). @@ -346,7 +363,7 @@ async function preExecutionYaraBlock( if (ctx === 'output') observeTransportLeak(tool, content); let matches = await scanAndTriage(content, ctx, triage); - if (ctx === 'output' && isWizardDocumentationPath(str(input.path))) { + if (ctx === 'output' && isWizardDocumentationPath(piToolPath(input.path))) { matches = matches.filter((m) => m.metadata.category !== 'posthog_pii'); } // Any match blocks — except publish_handoff, critical only. @@ -389,15 +406,9 @@ export async function evaluateToolCall( const decision = wizardCanUseTool(policy.name, policy.input, { disallowedTools: ctx.disallowedTools, wizardAskPending: ctx.getWizardAskPending?.() ?? false, + workingDirectory: ctx.workingDirectory, }); - // The allowlist is a pi-only restriction; the anthropic arm runs bash - // unrestricted and leans on the shared YARA scan. Let a plain `rm` of - // project files through to that same scan so pi matches that behavior. - const allowedLikeAnthropic = - toolName === 'bash' && - isScopedFileRemoval(str(input.command), ctx.workingDirectory); - - if (decision.behavior === 'deny' && !allowedLikeAnthropic) { + if (decision.behavior === 'deny') { return { block: true, reason: decision.message }; } @@ -442,13 +453,26 @@ export interface SecurityState { toolCalls: number; } +/** Options for {@link createSecurityExtension}. The root is required, so no run loses the rm allowance or the shrink guard by omission. */ +export type SecurityExtensionOptions = ToolGateContext & { + workingDirectory: string; +}; + +declare const subagentGate: unique symbol; + +/** A gate with no project root, so it never allows rm. Only `subagentFactory` makes one, so a subagent can't be handed the parent's. */ +export type SubagentSecurityFactory = ((pi: PiExtensionApiLike) => void) & { + readonly [subagentGate]: true; +}; + /** * Build the pi security extension + the shared state the backend inspects. - * Install the returned factory via `extensionFactories`; pass the same factory - * into every subagent session so the fence is inherited. + * Install `factory` via `extensionFactories`. Give subagent sessions + * `subagentFactory`: the same fence and state, with no project root, so no rm. */ -export function createSecurityExtension(ctx: ToolGateContext = {}): { +export function createSecurityExtension(ctx: SecurityExtensionOptions): { factory: (pi: PiExtensionApiLike) => void; + subagentFactory: SubagentSecurityFactory; state: SecurityState; } { const state: SecurityState = { @@ -468,7 +492,7 @@ export function createSecurityExtension(ctx: ToolGateContext = {}): { repeatTracker: ctx.repeatTracker ?? createRepeatBlockTracker(), }; - const factory = (pi: PiExtensionApiLike): void => { + const install = (pi: PiExtensionApiLike, gate: ToolGateContext): void => { pi.on('tool_call', async (event) => { // A latched post-scan violation blocks everything that follows. if (state.criticalViolation) { @@ -487,7 +511,7 @@ export function createSecurityExtension(ctx: ToolGateContext = {}): { const decision = await evaluateToolCall( event.toolName, event.input ?? {}, - gateCtx, + gate, llmProvider, ); if (decision.block) { @@ -539,7 +563,16 @@ export function createSecurityExtension(ctx: ToolGateContext = {}): { }); }; - return { factory, state }; + const subagentCtx: ToolGateContext = { + ...gateCtx, + workingDirectory: undefined, + }; + return { + factory: (pi) => install(pi, gateCtx), + subagentFactory: ((pi: PiExtensionApiLike) => + install(pi, subagentCtx)) as SubagentSecurityFactory, + state, + }; } /** diff --git a/src/agent/runner/harness/pi/subagent.ts b/src/agent/runner/harness/pi/subagent.ts index 1238ca262..3b5b3d8b9 100644 --- a/src/agent/runner/harness/pi/subagent.ts +++ b/src/agent/runner/harness/pi/subagent.ts @@ -5,10 +5,10 @@ * about (it can't propagate the parent's disallowedTools into subagents). * * Controls on every child: - * - the SAME security extension (canUseTool + YARA, fail-closed) — shared state, - * so the child shares the parent's tool-call cap and violation latch; - * - a read-only built-in toolset (read/grep/find/ls + allowlisted bash) — no - * write/edit, so a subagent can research but never mutate the project; + * - the parent's subagent security gate (canUseTool + YARA, fail-closed, no + * `rm`) — shared state, so the child shares the tool-call cap and latch; + * - a read-only built-in toolset (read/grep/find/ls) plus allowlisted bash — + * no write/edit tools; * - no custom tools — no .env writes, and crucially no `dispatch_agent`, so a * child cannot recurse (depth is hard-capped at 1). */ @@ -18,6 +18,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; import { logToFile } from '@utils/debug'; import { gatewayTerminalFailure } from './gateway'; +import type { SubagentSecurityFactory } from './security'; /** * Read-only built-ins a subagent may use. bash is supplied separately as the @@ -63,8 +64,8 @@ export interface SubagentContext { modelRegistry: import('@earendil-works/pi-coding-agent').ModelRegistry; cwd: string; agentDir: string; - /** The parent's security extension factory — reused so the fence is inherited. */ - securityFactory: (pi: unknown) => void; + /** The parent's subagent gate: the same fence and shared state, with no rm. */ + securityFactory: SubagentSecurityFactory; /** The parent's env-scrubbed bash, so a subagent's subprocesses are locked down too. */ bashTool: ToolDefinition; /** pi SDK entrypoints, already imported by the backend. */ @@ -101,7 +102,7 @@ export function createDispatchAgentTool(ctx: SubagentContext): ToolDefinition { noContextFiles: true, noPromptTemplates: true, noThemes: true, - extensionFactories: [ctx.securityFactory], + extensionFactories: [ctx.securityFactory as (pi: unknown) => void], }); await loader.reload(); diff --git a/src/agent/runner/harness/pi/task.ts b/src/agent/runner/harness/pi/task.ts index 84276a573..d2db73129 100644 --- a/src/agent/runner/harness/pi/task.ts +++ b/src/agent/runner/harness/pi/task.ts @@ -296,6 +296,7 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { disallowedTools: fenceDisallowList(disallowedTools), triageProvider: boot.triageProvider, getWizardAskPending: () => askState.pending, + workingDirectory: input.installDir, }); const { prewarmYaraScanner } = await import('../../../yara-hooks'); void prewarmYaraScanner(); diff --git a/src/shared/utils/env-scan.ts b/src/shared/utils/env-scan.ts index 38aaec438..d42b6477a 100644 --- a/src/shared/utils/env-scan.ts +++ b/src/shared/utils/env-scan.ts @@ -18,6 +18,7 @@ */ import path from 'path'; +import { minimatch } from 'minimatch'; import { walkProjectFiles, safeReadFile } from './bounded-fs'; /** @@ -42,6 +43,132 @@ export const TEMPLATE_ENV_FILE_NAMES: readonly string[] = [ '.env.dist', ]; +/** {@link isEnvFileName} for access guards: APFS and NTFS open `.ENV` as `.env`. */ +export function isEnvFileNameAnyCase(name: string): boolean { + return isEnvFileName(name.toLowerCase()); +} + +/** Stage words that follow `.env.` in real projects; used only to test globs that start with a wildcard. */ +const ENV_STAGE_WORDS = [ + 'local', + 'development', + 'dev', + 'production', + 'prod', + 'staging', + 'stage', + 'test', + 'testing', + 'ci', + 'preview', + 'qa', + 'uat', + 'sandbox', + 'example', + 'sample', + 'template', + 'dist', +]; + +const ENV_NAME_CANDIDATES = [ + '.env', + '.envrc', + ...ENV_STAGE_WORDS.flatMap((stage) => [ + `.env.${stage}`, + `.env.${stage}.local`, + `.env.local.${stage}`, + ]), +]; + +const GLOB_SPECIAL_CHARS = /[*?[\\(!+@]/; + +/** Project-relative POSIX paths of the `.env*` files under `rootDir`, same bounded walk as the key scan. */ +export function listProjectEnvFiles(rootDir: string): string[] { + const files: string[] = []; + walkProjectFiles( + rootDir, + (name, fullPath) => { + if (isEnvFileName(name)) + files.push(toRelativePosixPath(rootDir, fullPath)); + }, + ENV_SCAN_MAX_DEPTH, + ); + return files; +} + +const GLOB_MATCH_OPTIONS = { dot: true, nocase: true }; + +/** Whether `alternative` selects `relativePath` the way ripgrep reads a glob: by basename, or by path from the search root. */ +function globMatchesFile(alternative: string, relativePath: string): boolean { + const basename = path.posix.basename(relativePath); + return ( + minimatch(basename, alternative, GLOB_MATCH_OPTIONS) || + minimatch(relativePath, alternative, GLOB_MATCH_OPTIONS) || + minimatch(relativePath, `**/${alternative}`, GLOB_MATCH_OPTIONS) + ); +} + +/** + * True when one brace-free glob can select a file whose name starts with `.env`. + * `existingEnvFiles` is read only for a leading-wildcard name, and only once. + */ +function globAlternativeCanSelectEnvFile( + glob: string, + existingEnvFiles: () => readonly string[], +): boolean { + const segments = glob.split('/').filter((segment) => segment !== ''); + const last = segments[segments.length - 1]; + if (last === undefined) return false; + + const special = last.search(GLOB_SPECIAL_CHARS); + const literalPrefix = ( + special === -1 ? last : last.slice(0, special) + ).toLowerCase(); + if (special === -1 || literalPrefix !== '') { + // The name is fixed up to its first wildcard, so the prefix decides: + // `.env.prod*` and `.e*` can reach an env file, `app*` and `.git*` cannot. + return literalPrefix.startsWith('.env') || '.env'.startsWith(literalPrefix); + } + // A leading wildcard (`*`, `?env`, `*.production`) can match any suffix. + // Decide against the env files that exist, so `*.foo` is denied when + // `.env.foo` is there. The usual names stay denied even when absent, so a + // file the walk cannot reach (deep, hidden directory, created later) is + // still covered. `*.ts` passes both. + if ( + ENV_NAME_CANDIDATES.some((name) => + minimatch(name, last, GLOB_MATCH_OPTIONS), + ) + ) { + return true; + } + return existingEnvFiles().some((file) => globMatchesFile(glob, file)); +} + +/** + * True when a ripgrep `--glob` can select a `.env*` file. Such a glob overrides + * `.gitignore`, and ripgrep's `*` matches dotfiles. The glob's last segment is + * what names the file, so that segment decides. A leading `!` only excludes + * files from the search, so it never selects one. + * + * `searchRoot` is the directory the Grep searches (its `path` argument, or the + * project root); a leading-wildcard glob is checked against the env files under + * it. Without one, only the usual env names are checked. + */ +export function globCanSelectEnvFile( + glob: string, + searchRoot?: string, +): boolean { + if (glob.startsWith('!')) return false; + let listed: readonly string[] | undefined; + const existingEnvFiles = () => + (listed ??= searchRoot ? listProjectEnvFiles(searchRoot) : []); + return minimatch + .braceExpand(glob) + .some((alternative) => + globAlternativeCanSelectEnvFile(alternative, existingEnvFiles), + ); +} + /** * Committed template files: `.env.example` and its conventional siblings. They * document the keys a project expects and hold placeholders, not credentials,