Removes Voyage's measurement, statistics and export apparatus (decision 189; condition 175 met in step 1) and the R-a leftovers: lib/stats/, lib/exporters/, the otel-export and post-bash-stats hooks, yardstick, end-state and comparison gates, synthesis-measure, gold-scorer, docs/observability.md, examples/observability/, docs/T1-cc26-delegated-orchestration.md and their tests and fixtures. The six command stats phases and the /trekexecute main-merge event blocks go; intent-approval --stamp no longer emits brief-approved. README and docs point to Claude Code's own OpenTelemetry. Files removed: 52. Files edited: 37. npm test: 2930 tests (2928 pass / 0 fail / 2 skipped) before, 2600 tests (2598 pass / 0 fail / 2 skipped) after. removed-surfaces group R1 green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
4.9 KiB
JavaScript
119 lines
4.9 KiB
JavaScript
// lib/validators/query-privacy-gate.mjs
|
|
// Inspect an outbound research query before it leaves the machine. Called
|
|
// only from the new high-effort steps (Phase 4.5 dimension discovery + the
|
|
// bounded Phase 5 loop turns) — the existing single-pass Phase 5 path is
|
|
// unchanged (Step 6, plan-v2).
|
|
//
|
|
// Two-tier:
|
|
// - WARN tier — absolute filesystem paths, repo-internal identifiers.
|
|
// Operator-overridable via `strict: false` / `--soft` (matches
|
|
// lib/validators/research-validator.mjs's strict/soft convention), and
|
|
// fully bypassable via the VOYAGE_QUERY_PRIVACY_ALLOW=1 opt-in.
|
|
// - HARD-BLOCK tier — secret-shaped tokens. NEVER overridable by strict,
|
|
// --soft, or the opt-in env var: an opt-in widens the warn tier but never
|
|
// unlocks the permanently-blocked one.
|
|
//
|
|
// CLI shim:
|
|
// node lib/validators/query-privacy-gate.mjs [--soft] "<query text>"
|
|
// → JSON {valid, errors, warnings}; exit 0 valid, 1 invalid.
|
|
|
|
import { issue } from '../util/result.mjs';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
|
|
// WARN tier — absolute filesystem paths (leaks local directory layout).
|
|
export const ABSOLUTE_PATH_PATTERNS = Object.freeze([
|
|
/\/Users\/[^\s"'`]+/,
|
|
/\/home\/[^\s"'`]+/,
|
|
/[A-Za-z]:\\[^\s"'`]+/,
|
|
/\$\{?HOME\}?\/[^\s"'`]+/,
|
|
]);
|
|
|
|
// WARN tier — repo-internal identifiers that don't need to leave the
|
|
// machine in a generic research query.
|
|
export const REPO_IDENTIFIER_PATTERNS = Object.freeze([
|
|
/git\.fromaitochitta\.com[^\s"'`]*/,
|
|
/\bktg-plugin-marketplace\b/,
|
|
/\bplugins\/cache\/[^\s"'`]+/,
|
|
]);
|
|
|
|
// HARD-BLOCK tier — secret-shaped strings. Never operator-overridable.
|
|
//
|
|
// Token bodies that contain `-` or `_` break a plain `[A-Za-z0-9]{n,}` run, so
|
|
// each such format needs its own pattern rather than relying on run length:
|
|
// an Anthropic Console key runs out after `api03` (3 alphanumerics), and a
|
|
// fine-grained GitHub PAT after its 22-character segment. Patterns whose body
|
|
// class includes `-`/`_` carry no trailing `\b`, which would not fire on a
|
|
// non-word final character.
|
|
export const SECRET_SHAPED_PATTERNS = Object.freeze([
|
|
/\bsk-[A-Za-z0-9]{20,}\b/, // OpenAI-style API key (sk-<48>)
|
|
/\bsk-ant-[a-z0-9]+-[A-Za-z0-9_-]{20,}/, // Anthropic Console key (sk-ant-api03-/-oat01- + ~95 base64url)
|
|
/\bAKIA[0-9A-Z]{16}\b/, // AWS access key ID
|
|
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/, // GitHub classic PAT / OAuth / user / server / refresh token
|
|
/\bgithub_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT (github_pat_<22>_<59>)
|
|
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, // Slack token
|
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private key block
|
|
]);
|
|
|
|
function findMatch(patterns, text) {
|
|
for (const re of patterns) {
|
|
const m = re.exec(text);
|
|
if (m) return m[0];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {{strict?: boolean, env?: object}} [opts]
|
|
* @returns {{valid: boolean, errors: import('../util/result.mjs').Issue[], warnings: import('../util/result.mjs').Issue[]}}
|
|
*/
|
|
export function validateOutboundQuery(text, opts = {}) {
|
|
const strict = opts.strict !== false;
|
|
const env = opts.env || process.env;
|
|
// Bypasses the WARN tier entirely — never affects the hard-block tier below.
|
|
const allowWarnTier = env.VOYAGE_QUERY_PRIVACY_ALLOW === '1';
|
|
|
|
if (typeof text !== 'string' || text.length === 0) {
|
|
return { valid: false, errors: [issue('PRIVACY_EMPTY_QUERY', 'Outbound query must be a non-empty string')], warnings: [] };
|
|
}
|
|
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
// Hard-block tier — checked unconditionally; no opt-in reaches this branch.
|
|
const secretMatch = findMatch(SECRET_SHAPED_PATTERNS, text);
|
|
if (secretMatch) {
|
|
errors.push(issue('PRIVACY_SECRET_SHAPED', `Outbound query contains a secret-shaped token: ${secretMatch}`));
|
|
}
|
|
|
|
if (!allowWarnTier) {
|
|
const pathMatch = findMatch(ABSOLUTE_PATH_PATTERNS, text);
|
|
if (pathMatch) {
|
|
const issueObj = issue('PRIVACY_ABSOLUTE_PATH', `Outbound query contains an absolute filesystem path: ${pathMatch}`);
|
|
if (strict) errors.push(issueObj); else warnings.push(issueObj);
|
|
}
|
|
|
|
const repoMatch = findMatch(REPO_IDENTIFIER_PATTERNS, text);
|
|
if (repoMatch) {
|
|
const issueObj = issue('PRIVACY_REPO_IDENTIFIER', `Outbound query contains a repo-internal identifier: ${repoMatch}`);
|
|
if (strict) errors.push(issueObj); else warnings.push(issueObj);
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, warnings };
|
|
}
|
|
|
|
// ---- CLI shim ----------------------------------------------------------------
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const strict = !args.includes('--soft');
|
|
const text = args.find(a => !a.startsWith('--'));
|
|
if (text === undefined) {
|
|
process.stderr.write('Usage: query-privacy-gate.mjs [--soft] "<query text>"\n');
|
|
process.exit(2);
|
|
}
|
|
const r = validateOutboundQuery(text, { strict });
|
|
process.stdout.write(JSON.stringify(r) + '\n');
|
|
process.exit(r.valid ? 0 : 1);
|
|
}
|