feat(validators): add outbound query privacy gate for research egress
This commit is contained in:
parent
abc5bd8967
commit
0a569eec55
2 changed files with 253 additions and 0 deletions
143
tests/validators/query-privacy-gate.test.mjs
Normal file
143
tests/validators/query-privacy-gate.test.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// 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);
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
// ---- 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue