Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions src/agent/__tests__/bash-fence-rm.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
}
});
181 changes: 181 additions & 0 deletions src/agent/__tests__/wizard-can-use-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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\] <file\.\.\.>/,
);
});

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`, () => {
Expand Down
Loading
Loading