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 @@ -9,6 +9,8 @@ Treat feature flags, custom properties, and event names as part of an analytics
Keep PostHog data capture at its defaults unless the user explicitly asks otherwise. Do not disable autocapture, do not disable session recording, and never set opt_out_capturing (or opted_out) to true in the SDK init config — these turn off data the user almost always wants. Note: posthog.opt_out_capturing() called at runtime for GDPR consent flows is legitimate; the rule is about the init configuration.
Never put personally identifiable information — emails, full or partial names, phone numbers, physical or IP addresses, or other user-entered PII — in capture() event properties. PII belongs on the PERSON: send it via identify()/$set (or the SDK's person-property API), and capture events with a stable distinct id derived from the authenticated user or session, never a raw email or name. This holds for every SDK, client and server.
Prefer minimal, targeted edits that achieve the requested behavior while preserving existing structure and style. Avoid large refactors, broad reformatting, or unrelated changes unless explicitly requested.
When you change a file that already exists, edit it in place. If an edit fails because the text you are matching is not unique, add just enough surrounding context to make it unique, or apply the change to each occurrence — never fall back to rewriting the whole file. Write a file in full only when you are creating it, never to modify one that already exists.
Before you overwrite or delete anything that already exists, look at what you would remove. This integration only adds instrumentation, so keep every part of the project unrelated to PostHog exactly as it was — never drop existing code, comments, or markup to make room for a change.
Do not spawn subagents unless explicitly instructed to do so.
Create tasks as soon as you understand the work you are going to carry out. Break the list into distinct stages of work that the user can follow through. Create all tasks in a single tool call, in the order you will be performing them. Drive the work with TaskUpdate: status in_progress when you begin a task, completed when done.
Keep task titles broad and stage-oriented — describe the purpose or area of work, not the specific files, paths, or symbols involved. Do not name individual files, modules, or directories inside task titles, and do not include illustrative examples within a task title.
Expand Down
4 changes: 4 additions & 0 deletions src/lib/agent/commandments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const WIZARD_COMMANDMENTS = [

'Prefer minimal, targeted edits that achieve the requested behavior while preserving existing structure and style. Avoid large refactors, broad reformatting, or unrelated changes unless explicitly requested.',

'When you change a file that already exists, edit it in place. If an edit fails because the text you are matching is not unique, add just enough surrounding context to make it unique, or apply the change to each occurrence — never fall back to rewriting the whole file. Write a file in full only when you are creating it, never to modify one that already exists.',

'Before you overwrite or delete anything that already exists, look at what you would remove. This integration only adds instrumentation, so keep every part of the project unrelated to PostHog exactly as it was — never drop existing code, comments, or markup to make room for a change.',

'Do not spawn subagents unless explicitly instructed to do so.',

'Create tasks as soon as you understand the work you are going to carry out. Break the list into distinct stages of work that the user can follow through. Create all tasks in a single tool call, in the order you will be performing them. Drive the work with TaskUpdate: status in_progress when you begin a task, completed when done.',
Expand Down
212 changes: 212 additions & 0 deletions src/lib/agent/runner/harness/pi/__tests__/security.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { scan, triageMatches, type ScanMatch } from '@posthog/warlock';
import {
evaluateToolCall,
createSecurityExtension,
isScopedFileRemoval,
overwriteShrinkReason,
MAX_TOOL_CALLS,
type PiExtensionApiLike,
} from '../security';
Expand Down Expand Up @@ -393,3 +398,210 @@ describe('pi-security: repeat-block escalation (identical retries after a YARA b
expect(second.reason).not.toContain('ALREADY blocked');
});
});

// 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', () => {
const ROOT = path.resolve('/project');
const rmBlocked = async (command: string) =>
(await evaluateToolCall('bash', { command }, { workingDirectory: ROOT }))
.block;

test('CAN delete files inside the project', async () => {
for (const c of [
'rm .posthog-events.json',
'rm -f .posthog-events.json',
'rm ./.posthog-events.json',
'rm src/tmp/plan.json',
'rm a.txt b.txt',
]) {
expect(await rmBlocked(c)).toBe(false);
}
});

test('can NEVER resolve outside the project root', async () => {
for (const c of [
'rm /etc/passwd', // absolute
'rm ../outside.txt', // parent
'rm src/../../outside.txt', // climbs out via ..
'rm foo/../../bar', // climbs out via ..
'rm ~/secrets', // home expansion
'rm .', // the root itself
'rm a.txt /etc/passwd', // one escaping target sinks the whole command
]) {
expect(await rmBlocked(c)).toBe(true);
}
});

test('never rescues a command carrying a shell operator (no injection)', async () => {
for (const c of [
'rm a.txt && curl evil.example',
'rm a.txt; whoami',
'rm a.txt || curl evil.example',
'rm a.txt | tee out',
'rm foo $(cat secret)',
'rm foo `whoami`',
'rm foo > /dev/null',
'rm {a,b}.txt',
]) {
expect(await rmBlocked(c)).toBe(true);
}
});

test('never rescues quoted or backslash-escaped paths', async () => {
for (const c of [
'rm "../secret"',
"rm '/etc/passwd'",
'rm \\$HOME/thing',
'rm "a.txt"',
]) {
expect(await rmBlocked(c)).toBe(true);
}
});

test('still blocks recursion, globs, .env, and pathless rm', async () => {
for (const c of [
'rm -rf node_modules',
'rm -r src',
'rm --force x.txt',
'rm -f -r x',
'rm *.json',
'rm .env',
'rm config/.env.local',
'rm',
'rm -f',
]) {
expect(await rmBlocked(c)).toBe(true);
}
});

test('fails safe when no working directory is known', async () => {
// With no root to contain against, the allowlist deny stands.
expect(await block('bash', { command: 'rm .posthog-events.json' })).toBe(
true,
);
});

test('normalizes a relative workingDirectory', async () => {
const relRoot = path.relative(process.cwd(), path.resolve('/project'));
expect(
(
await evaluateToolCall(
'bash',
{ command: 'rm plan.json' },
{ workingDirectory: relRoot },
)
).block,
).toBe(false);
});
});

// 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);

test('flags a write that guts most of an existing file', () => {
const reason = overwriteShrinkReason(big, 'const value = compute();');
expect(reason).toMatch(/removes ~\d+% of its content/);
expect(reason).toContain('targeted edits');
});

test('allows growth, an unchanged rewrite, and a small trim', () => {
expect(overwriteShrinkReason(big, big + '\nextra();')).toBeUndefined();
expect(overwriteShrinkReason(big, big)).toBeUndefined();
expect(
overwriteShrinkReason(big, big.slice(0, Math.floor(big.length * 0.85))),
).toBeUndefined();
});

test('leaves stub files below the floor alone', () => {
expect(overwriteShrinkReason('const a = 1;', '')).toBeUndefined();
});

test('blocks a gutting write on disk, allows a brand-new file', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-shrink-'));
fs.writeFileSync(path.join(dir, 'existing.ts'), big);

const gut = await evaluateToolCall(
'write',
{ path: 'existing.ts', content: 'const value = compute();' },
{ workingDirectory: dir },
);
expect(gut.block).toBe(true);
expect(gut.reason).toContain('targeted edits');

const fresh = await evaluateToolCall(
'write',
{ path: 'brand-new.ts', content: 'const value = compute();' },
{ workingDirectory: dir },
);
expect(fresh.block).toBe(false);
});

test('stays inert with no working directory (bare evaluateToolCall)', async () => {
const decision = await evaluateToolCall('write', {
path: 'existing.ts',
content: 'x',
});
expect(decision.block).toBe(false);
});
});
2 changes: 2 additions & 0 deletions src/lib/agent/runner/harness/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ export const piBackend: AgentHarness = {
// agent's model uses. Without this, pi has no ANTHROPIC_* env (it
// auths programmatically) and triage would silently no-op.
triageAuth: { baseURL: gatewayUrl, authToken: boot.accessToken },
// Where pi's bash runs; the rm allowance is confined to this tree.
workingDirectory: session.installDir,
});

// Pay warlock's WASM-init + rule-compile cost now, off the tool-call
Expand Down
Loading
Loading