// tests/validators/query-privacy-gate.test.mjs // Cover lib/validators/query-privacy-gate.mjs: two-sided code table // (absolute path / repo-internal identifier / secret-shaped token), a // benign query passing untouched, the opt-in env var reaching only the // warn tier (never the hard-block tier), strict/soft severity, and the // CLI shim. // // Secret-shaped fixtures are built via string concatenation/repeat, never // as literal tokens — the repo's own secrets pre-edit hook (correctly) // treats a literal AKIA/sk-/ghp_ string as a real credential. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { validateOutboundQuery, ABSOLUTE_PATH_PATTERNS, REPO_IDENTIFIER_PATTERNS, SECRET_SHAPED_PATTERNS, } from '../../lib/validators/query-privacy-gate.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SHIM = join(HERE, '..', '..', 'lib', 'validators', 'query-privacy-gate.mjs'); const FAKE_OPENAI_KEY = 'sk-' + 'a'.repeat(24); const FAKE_AWS_KEY = 'AKIA' + 'Q'.repeat(16); const FAKE_GITHUB_PAT = 'ghp_' + 'b'.repeat(36); // Real-world formats whose token body contains hyphens/underscores. A run of // plain alphanumerics is broken by those separators, so a naive // `[A-Za-z0-9]{20,}` run-length pattern lets them through — the gap this file // pins. Anthropic Console keys are `sk-ant-api03-` + ~95 base64url chars; // GitHub fine-grained PATs are `github_pat_<22>_<59>`; `gho_` is the OAuth // sibling of the classic `ghp_` token. const FAKE_ANTHROPIC_KEY = 'sk-' + 'ant-' + 'api03-' + 'A1b2_-x9'.repeat(12); const FAKE_GITHUB_FINE_GRAINED = 'github' + '_pat_' + 'A'.repeat(22) + '_' + 'c'.repeat(59); const FAKE_GITHUB_OAUTH = 'gho' + '_' + 'd'.repeat(36); function runShim(args) { try { const out = execFileSync(process.execPath, [SHIM, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); return { code: 0, out }; } catch (e) { return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' }; } } // ---- two-sided code table ---------------------------------------------------- const TABLE = [ { label: 'absolute path (/Users/...)', text: 'find every caller of foo in /Users/ktg/repos/voyage/lib/util/foo.mjs', code: 'PRIVACY_ABSOLUTE_PATH' }, { label: 'absolute path (/home/...)', text: 'trace /home/alice/projects/app/src/index.js for imports', code: 'PRIVACY_ABSOLUTE_PATH' }, { label: 'repo-internal identifier (forgejo host)', text: 'what changed recently on git.fromaitochitta.com/open/voyage', code: 'PRIVACY_REPO_IDENTIFIER' }, { label: 'repo-internal identifier (repo name)', text: 'search issues for ktg-plugin-marketplace regressions', code: 'PRIVACY_REPO_IDENTIFIER' }, { label: 'secret-shaped (OpenAI/Anthropic-style key)', text: `auth failing with key ${FAKE_OPENAI_KEY}`, code: 'PRIVACY_SECRET_SHAPED' }, { label: 'secret-shaped (AWS access key)', text: `rotate ${FAKE_AWS_KEY} now`, code: 'PRIVACY_SECRET_SHAPED' }, { label: 'secret-shaped (GitHub PAT)', text: `token leaked: ${FAKE_GITHUB_PAT}`, code: 'PRIVACY_SECRET_SHAPED' }, { label: 'secret-shaped (Anthropic Console key)', text: `why does ${FAKE_ANTHROPIC_KEY} 401`, code: 'PRIVACY_SECRET_SHAPED' }, { label: 'secret-shaped (GitHub fine-grained PAT)', text: `pushed with ${FAKE_GITHUB_FINE_GRAINED}`, code: 'PRIVACY_SECRET_SHAPED' }, { label: 'secret-shaped (GitHub OAuth token)', text: `oauth flow returned ${FAKE_GITHUB_OAUTH}`, code: 'PRIVACY_SECRET_SHAPED' }, ]; for (const { label, text, code } of TABLE) { test(`validateOutboundQuery — ${label} → ${code} (strict, error)`, () => { const r = validateOutboundQuery(text, { strict: true, env: {} }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === code), JSON.stringify(r.errors)); }); } test('validateOutboundQuery — benign generic query passes untouched', () => { const r = validateOutboundQuery('What are the tradeoffs between optimistic and pessimistic locking?', { env: {} }); assert.equal(r.valid, true); assert.deepEqual(r.errors, []); assert.deepEqual(r.warnings, []); }); // ---- strict vs soft (warn tier only) ----------------------------------------- test('validateOutboundQuery — soft mode downgrades warn-tier findings to warnings, stays valid', () => { const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { strict: false, env: {} }); assert.equal(r.valid, true); assert.equal(r.errors.length, 0); assert.ok(r.warnings.find(w => w.code === 'PRIVACY_ABSOLUTE_PATH')); }); test('validateOutboundQuery — soft mode does NOT downgrade the hard-block tier', () => { const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { strict: false, env: {} }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED')); }); // ---- opt-in env var reaches only the warn tier ------------------------------- test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 bypasses the warn tier entirely', () => { const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } }); assert.equal(r.valid, true); assert.equal(r.errors.length, 0); assert.equal(r.warnings.length, 0); }); test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 does NOT open the hard-block tier', () => { const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'), 'opt-in must never unlock the hard-block tier'); }); test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 combined with a secret still denies', () => { const r = validateOutboundQuery(`/Users/ktg/x leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } }); assert.equal(r.valid, false); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'PRIVACY_SECRET_SHAPED'); }); // The hard-block tier is the one thing no operator flag unlocks, so a format // it misses is a secret leaving the machine with no second gate behind it. // Pin every real-world format against BOTH escape hatches at once. for (const [label, token] of [ ['Anthropic Console key', FAKE_ANTHROPIC_KEY], ['GitHub fine-grained PAT', FAKE_GITHUB_FINE_GRAINED], ['GitHub OAuth token', FAKE_GITHUB_OAUTH], ]) { test(`validateOutboundQuery — ${label} stays blocked under --soft and the opt-in`, () => { const r = validateOutboundQuery(`leaked ${token}`, { strict: false, env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' }, }); assert.equal(r.valid, false, 'hard-block tier must never be overridable'); assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED')); }); } // ---- empty input -------------------------------------------------------------- test('validateOutboundQuery — empty string is invalid', () => { const r = validateOutboundQuery('', { env: {} }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PRIVACY_EMPTY_QUERY')); }); // ---- pattern set is frozen ---------------------------------------------------- test('pattern sets are Object.frozen', () => { assert.equal(Object.isFrozen(ABSOLUTE_PATH_PATTERNS), true); assert.equal(Object.isFrozen(REPO_IDENTIFIER_PATTERNS), true); assert.equal(Object.isFrozen(SECRET_SHAPED_PATTERNS), true); }); // ---- CLI shim ----------------------------------------------------------------- test('CLI shim — benign query exits 0 with valid:true', () => { const r = runShim(['harmless generic question about caching strategies']); assert.equal(r.code, 0); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.valid, true); }); test('CLI shim — secret-shaped query exits 1 even with --soft', () => { const r = runShim(['--soft', `leaked ${FAKE_OPENAI_KEY}`]); assert.equal(r.code, 1); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.valid, false); assert.ok(parsed.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED')); }); test('CLI shim — missing query argument exits 2 (usage error)', () => { const r = runShim([]); assert.equal(r.code, 2); });