Skip to content
Closed
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
98 changes: 98 additions & 0 deletions src/lib/__tests__/yara-scanner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { scan } from '@lib/yara-scanner';

/** Rule names matched when `content` is scanned as written (Write/Edit) output. */
function writeMatches(content: string): string[] {
const result = scan(content, 'PostToolUse', 'Write');
return result.matched ? result.matches.map((m) => m.rule.name) : [];
}

describe('pii_in_capture_call', () => {
test('matches PII keys at the top level of capture() properties', () => {
const cases = [
`posthog.capture('signup', { email: user.email })`,
`posthog.capture('signup', { 'email': value })`,
`client.capture('purchase', { credit_card: cc })`,
`posthog.capture('profile_saved', { firstName: name })`,
`posthog.capture('kyc', { ssn: input.ssn })`,
];
for (const code of cases) {
expect(writeMatches(code)).toContain('pii_in_capture_call');
}
});

test('matches sensitive PII in identify() but allows standard identifying fields', () => {
expect(writeMatches(`posthog.identify(id, { ssn: user.ssn })`)).toContain(
'pii_in_capture_call',
);
expect(
writeMatches(`posthog.identify(id, { email: user.email, name })`),
).toEqual([]);
});

// Field false positives from the 2026-07-07 triage — each remark quote is a
// fixture. These blocked real integrations ~4× per affected run on pi.

test('FP: event names containing a PII word do not match', () => {
const cases = [
`posthog.capture('email_verified', { method: 'oauth' })`,
`posthog.capture('email_sent')`,
`posthog.capture('phone_call_started', { durationBucket })`,
];
for (const code of cases) {
expect(writeMatches(code)).toEqual([]);
}
});

test('FP: minimal-payload auth events do not match', () => {
expect(
writeMatches(`posthog.capture('login_success', { provider: 'google' })`),
).toEqual([]);
});

test('FP: benign `location` (page section) property does not match', () => {
expect(
writeMatches(`posthog.capture('cta_clicked', { location: 'hero' })`),
).toEqual([]);
});

test('FP: PII inside $set does not match — it is the remediation we ask for', () => {
// The runtime notes tell the agent to move PII off the event and onto the
// person via identify()/$set; matching $set sent agents in circles.
const cases = [
`posthog.capture('signup', { $set: { email: user.email } })`,
`posthog.people.set({ email: user.email })`,
];
for (const code of cases) {
expect(writeMatches(code)).toEqual([]);
}
});

test('FP: substrings of benign keys do not match', () => {
expect(
writeMatches(`posthog.capture('form_submitted', { emailFieldCount: 2 })`),
).toEqual([]);
});
});

describe('hardcoded_posthog_host', () => {
test('matches a bare host literal', () => {
const cases = [
`posthog.init(key, { api_host: 'https://us.i.posthog.com' })`,
`const host = "https://eu.i.posthog.com";`,
];
for (const code of cases) {
expect(writeMatches(code)).toContain('hardcoded_posthog_host');
}
});

test('FP: host literal in fallback position does not match', () => {
const cases = [
`const host = process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com';`,
`api_host: import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com',`,
`host = os.environ.get('POSTHOG_HOST') or 'https://us.i.posthog.com'`,
];
for (const code of cases) {
expect(writeMatches(code)).toEqual([]);
}
});
});
32 changes: 32 additions & 0 deletions src/lib/agent/runner/harness/pi/__tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,35 @@ describe('pi-security: extension state machine (fail-closed + runaway + latch)',
expect(state.toolCalls).toBeGreaterThan(MAX_TOOL_CALLS);
});
});

describe('pi-security: edit scanning is scoped to the replacement text', () => {
test('an edit whose oldText holds a violation but whose newText is clean passes', () => {
// Field FP: pre-existing violations in the repo blocked their own
// replacement — the scan covered oldText (existing file content).
expect(
evaluateToolCall('edit', {
path: 'src/analytics.ts',
edits: [
{
oldText: `posthog.capture('signup', { email: user.email })`,
newText: `posthog.capture('signup', { plan: user.plan })`,
},
],
}).block,
).toBe(false);
});

test('an edit whose newText introduces a violation still blocks', () => {
const decision = evaluateToolCall('edit', {
path: 'src/analytics.ts',
edits: [
{
oldText: `posthog.capture('signup')`,
newText: `posthog.capture('signup', { email: user.email })`,
},
],
});
expect(decision.block).toBe(true);
expect(decision.reason).toContain('pii_in_capture_call');
});
});
10 changes: 9 additions & 1 deletion src/lib/agent/runner/harness/pi/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,15 @@ function preExecutionYaraBlock(
phase = 'PostToolUse';
break;
case 'edit':
content = JSON.stringify(input.edits ?? '');
// Scan ONLY the replacement text. `edits` is [{oldText, newText}] and
// oldText is pre-existing file content the agent is changing — scanning
// it blocked edits whose *surroundings* contained a violation, and even
// edits that were removing one (field FPs: capture edits adjacent to
// existing identify() PII; pre-existing violations blocking their own
// replacement). Matches the anthropic path, which scans new_string only.
content = (Array.isArray(input.edits) ? input.edits : [])
.map((e) => str((e as Record<string, unknown>)?.newText))
.join('\n');
target = 'Edit';
phase = 'PostToolUse';
break;
Expand Down
54 changes: 34 additions & 20 deletions src/lib/yara-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ const PRE_BASH: Array<{ phase: HookPhase; tool: ToolTarget }> = [

// ── §1 PostHog API Violations ────────────────────────────────────

// Matches warlock's posthog_pii_in_capture_call shape: a PII-named key at the
// top level of the first `{ ... }` object, followed by `:`/`=`. The previous
// looser patterns (`/\.capture\s*\([^)]{0,200}email/i`) matched the word
// anywhere inside the call — event NAMES (`capture('email_verified', ...)`),
// substrings of benign keys, and `$set` payloads all fired. Field FPs from the
// 2026-07-07 triage: minimal-payload auth events, a benign `location` property,
// and `$set` blocks — the exact remediation our own runtime notes tell the
// agent to use ("move the field onto the person via identify()/$set"), so
// matching `$set` sent agents in circles. `$set: { email }` is canonical and
// deliberately not matched, same as warlock.
const pii_in_capture_call: YaraRule = {
name: 'pii_in_capture_call',
description:
Expand All @@ -85,25 +95,23 @@ const pii_in_capture_call: YaraRule = {
category: 'posthog_pii',
appliesTo: POST_WRITE_EDIT,
patterns: [
// Direct PII field names in capture properties
/\.capture\s*\([^)]{0,200}email/i,
/\.capture\s*\([^)]{0,200}phone/i,
/\.capture\s*\([^)]{0,200}full[_\s]?name/i,
/\.capture\s*\([^)]{0,200}first[_\s]?name/i,
/\.capture\s*\([^)]{0,200}last[_\s]?name/i,
/\.capture\s*\([^)]{0,200}(street|mailing|home|billing)[_\s]?address/i,
/\.capture\s*\([^)]{0,200}(ssn|social[_\s]?security)/i,
/\.capture\s*\([^)]{0,200}(date[_\s]?of[_\s]?birth|dob|birthday)/i,
/\.capture\s*\([^)]{0,200}\$ip/,
// identify() allows email/phone/name (standard PostHog user properties),
// but highly sensitive PII is still blocked in identify().
/\.identify\s*\([^)]{0,200}(ssn|social[_\s]?security)/i,
/\.identify\s*\([^)]{0,200}(card[_\s]?number|cvv|credit[_\s]?card)/i,
/\.identify\s*\([^)]{0,200}(date[_\s]?of[_\s]?birth|dob|birthday)/i,
/\.identify\s*\([^)]{0,200}(street|mailing|home|billing)[_\s]?address/i,
// PII in $set properties via capture (bound to same object)
/\$set[^}]{0,200}email/i,
/\$set[^}]{0,200}phone/i,
// posthog.capture() — all PII fields are a problem here
/\.capture\s*\([^{]*\{[^{}]*\b(email_address|emailAddress|emailAddr|email)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\b(phone_number|phoneNumber|phoneNum|phoneNo|phone|mobile_number|mobileNumber|mobile|telephone)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\b(full_name|fullName|legal_name|legalName)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\b(first_name|firstName|fname)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\b(last_name|lastName|family_name|familyName|surname|lname)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\b(street_address|streetAddress|home_address|homeAddress|billing_address|billingAddress|mailing_address|mailingAddress)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\b(social_security_number|socialSecurityNumber|social_security|socialSecurity|ssn)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\b(date_of_birth|dateOfBirth|birth_date|birthDate|birthday|dob)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\b(credit_card|creditCard|card_number|cardNumber|cc_number|ccNumber|cvv|cvc)\b['"]?\s*[:=]/i,
/\.capture\s*\([^{]*\{[^{}]*\$ip\b['"]?\s*[:=]/,
// posthog.identify() — email/phone/names are standard identifying fields;
// only sensitive PII is blocked here.
/\.identify\s*\([^{]*\{[^{}]*\b(social_security_number|socialSecurityNumber|social_security|socialSecurity|ssn)\b['"]?\s*[:=]/i,
/\.identify\s*\([^{]*\{[^{}]*\b(credit_card|creditCard|card_number|cardNumber|cc_number|ccNumber|cvv|cvc)\b['"]?\s*[:=]/i,
/\.identify\s*\([^{]*\{[^{}]*\b(date_of_birth|dateOfBirth|birth_date|birthDate|birthday|dob)\b['"]?\s*[:=]/i,
/\.identify\s*\([^{]*\{[^{}]*\b(street_address|streetAddress|home_address|homeAddress|billing_address|billingAddress|mailing_address|mailingAddress)\b['"]?\s*[:=]/i,
],
};

Expand Down Expand Up @@ -151,7 +159,13 @@ const hardcoded_posthog_host: YaraRule = {
severity: 'high',
category: 'posthog_hardcoded_key',
appliesTo: POST_WRITE_EDIT,
patterns: [/['"]https:\/\/(us|eu)\.i\.posthog\.com['"]/],
patterns: [
// A host literal in FALLBACK position is the documented pattern
// (`process.env.POSTHOG_HOST || 'https://us.i.posthog.com'`, `?? '…'`,
// python `or '…'`) — the env var still wins, so don't block it. The
// lookbehind skips literals directly preceded by a fallback operator.
/(?<!(\|\||\?\?|\bor)\s{0,4})['"]https:\/\/(us|eu)\.i\.posthog\.com['"]/,
],
};

const session_recording_disabled: YaraRule = {
Expand Down
Loading