mirror of https://github.com/garrytan/gstack.git
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
/**
|
|
* Security classifier — ML prompt injection detection (L4, TestSavantAI).
|
|
*
|
|
* This module is IMPORTED ONLY BY security-sidecar-entry.ts and runs inside
|
|
* the security sidecar subprocess (plain Node, spawned lazily by
|
|
* security-sidecar-client.ts). It CANNOT be imported by server.ts or any
|
|
* other module that ends up in the compiled browse binary, because
|
|
* @huggingface/transformers requires onnxruntime-node at runtime and that
|
|
* native module fails to dlopen from Bun's compiled-binary temp extraction
|
|
* dir.
|
|
*
|
|
* See: 2026-04-19-prompt-injection-guard.md Pre-Impl Gate 1 outcome.
|
|
*
|
|
* Layer:
|
|
* L4 (testsavant_content) — TestSavantAI BERT-small ONNX classifier on page
|
|
* snapshots and tool outputs. Detects indirect
|
|
* prompt injection + jailbreak attempts.
|
|
*
|
|
* The classifier degrades gracefully — if the model fails to load, the layer
|
|
* reports status 'degraded' and returns verdict 'safe' (fail-open). The
|
|
* caller (server.ts's /pty-inject-scan path) falls through to its
|
|
* L1-L3-only verdict; only the extra ML defense disappears.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { mkdirSecure } from './file-permissions';
|
|
import { type LayerSignal } from './security';
|
|
|
|
// ─── Model location + packaging ──────────────────────────────
|
|
|
|
/**
|
|
* TestSavantAI prompt-injection-defender-small-v0-onnx.
|
|
*
|
|
* The HuggingFace repo stores model.onnx at the root, but @huggingface/transformers
|
|
* v4 expects it under an `onnx/` subdirectory. We stage the files into the expected
|
|
* layout at ~/.gstack/models/testsavant-small/ on first use.
|
|
*
|
|
* Files (fetched from HF on first use, cached for lifetime of install):
|
|
* config.json
|
|
* tokenizer.json
|
|
* tokenizer_config.json
|
|
* special_tokens_map.json
|
|
* vocab.txt
|
|
* onnx/model.onnx (~112MB)
|
|
*/
|
|
const MODELS_DIR = path.join(os.homedir(), '.gstack', 'models');
|
|
const TESTSAVANT_DIR = path.join(MODELS_DIR, 'testsavant-small');
|
|
const TESTSAVANT_HF_URL = 'https://huggingface.co/testsavantai/prompt-injection-defender-small-v0-onnx/resolve/main';
|
|
const TESTSAVANT_FILES = [
|
|
'config.json',
|
|
'tokenizer.json',
|
|
'tokenizer_config.json',
|
|
'special_tokens_map.json',
|
|
'vocab.txt',
|
|
];
|
|
|
|
// ─── Load state ──────────────────────────────────────────────
|
|
|
|
type LoadState = 'uninitialized' | 'loading' | 'loaded' | 'failed';
|
|
|
|
let testsavantState: LoadState = 'uninitialized';
|
|
let testsavantClassifier: any = null;
|
|
let testsavantLoadError: string | null = null;
|
|
|
|
export interface ClassifierStatus {
|
|
testsavant: 'ok' | 'degraded' | 'off';
|
|
}
|
|
|
|
export function getClassifierStatus(): ClassifierStatus {
|
|
const testsavant =
|
|
testsavantState === 'loaded' ? 'ok' :
|
|
testsavantState === 'failed' ? 'degraded' :
|
|
'off';
|
|
return { testsavant };
|
|
}
|
|
|
|
// ─── Model download + staging ────────────────────────────────
|
|
|
|
export async function downloadFile(url: string, dest: string): Promise<void> {
|
|
const res = await fetch(url);
|
|
if (!res.ok || !res.body) {
|
|
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
|
|
}
|
|
const tmp = `${dest}.tmp.${process.pid}`;
|
|
const writer = fs.createWriteStream(tmp);
|
|
// @ts-ignore — Node stream compat
|
|
const reader = res.body.getReader();
|
|
try {
|
|
let done = false;
|
|
while (!done) {
|
|
const chunk = await reader.read();
|
|
if (chunk.done) { done = true; break; }
|
|
writer.write(chunk.value);
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
writer.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
});
|
|
fs.renameSync(tmp, dest);
|
|
} catch (err) {
|
|
// Drop the half-written tmp so we don't ship a truncated model file to
|
|
// a retry's renameSync. Wait for the writer to close fully before
|
|
// unlinking: Node's createWriteStream lazily opens the FD and flushes
|
|
// buffered writes during destroy(), so a naive unlinkSync hits ENOENT
|
|
// first and the writer re-creates the file on the next tick.
|
|
await new Promise<void>((resolve) => {
|
|
writer.once('close', () => resolve());
|
|
writer.destroy();
|
|
});
|
|
try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function ensureTestsavantStaged(onProgress?: (msg: string) => void): Promise<void> {
|
|
mkdirSecure(path.join(TESTSAVANT_DIR, 'onnx'));
|
|
|
|
// Small config/tokenizer files
|
|
for (const f of TESTSAVANT_FILES) {
|
|
const dst = path.join(TESTSAVANT_DIR, f);
|
|
if (fs.existsSync(dst)) continue;
|
|
onProgress?.(`downloading ${f}`);
|
|
await downloadFile(`${TESTSAVANT_HF_URL}/${f}`, dst);
|
|
}
|
|
|
|
// Large model file — only download if missing. Put under onnx/ to match the
|
|
// layout @huggingface/transformers v4 expects.
|
|
const modelDst = path.join(TESTSAVANT_DIR, 'onnx', 'model.onnx');
|
|
if (!fs.existsSync(modelDst)) {
|
|
onProgress?.('downloading model.onnx (112MB) — first run only');
|
|
await downloadFile(`${TESTSAVANT_HF_URL}/model.onnx`, modelDst);
|
|
}
|
|
}
|
|
|
|
// ─── L4: TestSavantAI content classifier ─────────────────────
|
|
|
|
/**
|
|
* Load the TestSavantAI classifier. Idempotent — concurrent calls share the
|
|
* same in-flight promise. Sets state to 'loaded' on success or 'failed' on error.
|
|
*
|
|
* Called by the sidecar on the first scan-page-content request to warm up.
|
|
* First call triggers the model download (~112MB from HuggingFace).
|
|
* Subsequent calls reuse the cached instance.
|
|
*/
|
|
let loadPromise: Promise<void> | null = null;
|
|
|
|
export function loadTestsavant(onProgress?: (msg: string) => void): Promise<void> {
|
|
if (process.env.GSTACK_SECURITY_OFF === '1') {
|
|
testsavantState = 'failed';
|
|
testsavantLoadError = 'GSTACK_SECURITY_OFF=1 — ML classifier kill switch engaged';
|
|
return Promise.resolve();
|
|
}
|
|
if (testsavantState === 'loaded') return Promise.resolve();
|
|
if (loadPromise) return loadPromise;
|
|
testsavantState = 'loading';
|
|
loadPromise = (async () => {
|
|
try {
|
|
await ensureTestsavantStaged(onProgress);
|
|
// Dynamic import — keeps the module boundary clean so static analyzers
|
|
// don't pull @huggingface/transformers into compiled contexts.
|
|
onProgress?.('initializing classifier');
|
|
const { pipeline, env } = await import('@huggingface/transformers');
|
|
env.allowLocalModels = true;
|
|
env.allowRemoteModels = false;
|
|
env.localModelPath = MODELS_DIR;
|
|
testsavantClassifier = await pipeline(
|
|
'text-classification',
|
|
'testsavant-small',
|
|
{ dtype: 'fp32' },
|
|
);
|
|
// TestSavantAI's tokenizer_config.json ships with model_max_length
|
|
// set to a huge placeholder (1e18) which disables automatic truncation
|
|
// in the TextClassificationPipeline. The underlying BERT-small has
|
|
// max_position_embeddings: 512 — passing anything longer throws a
|
|
// broadcast error. Override via _tokenizerConfig (the internal source
|
|
// the computed model_max_length getter reads from) so the pipeline's
|
|
// implicit truncation: true actually kicks in.
|
|
const tok = testsavantClassifier?.tokenizer as any;
|
|
if (tok?._tokenizerConfig) {
|
|
tok._tokenizerConfig.model_max_length = 512;
|
|
}
|
|
testsavantState = 'loaded';
|
|
} catch (err: any) {
|
|
testsavantState = 'failed';
|
|
testsavantLoadError = err?.message ?? String(err);
|
|
console.error('[security-classifier] Failed to load TestSavantAI:', testsavantLoadError);
|
|
}
|
|
})();
|
|
return loadPromise;
|
|
}
|
|
|
|
/**
|
|
* Strip HTML tags and collapse whitespace. TestSavantAI was trained on
|
|
* plain text, not markup — feeding it raw HTML massively reduces recall
|
|
* because all the tag noise dilutes the injection signal. Callers that
|
|
* already have plain text (page snapshot innerText, tool output strings)
|
|
* get no-op behavior; callers with HTML get the markup stripped.
|
|
*/
|
|
function htmlToPlainText(input: string): string {
|
|
// Fast path: if no angle brackets, it's already plain text.
|
|
if (!input.includes('<')) return input;
|
|
return input
|
|
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ' ') // drop script/style bodies entirely
|
|
.replace(/<[^>]+>/g, ' ') // drop tags
|
|
.replace(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Scan text content for prompt injection. Intended for page snapshots, tool
|
|
* outputs, and other untrusted content blocks.
|
|
*
|
|
* Returns a LayerSignal. On load failure or classification error, returns
|
|
* confidence=0 with status flagged degraded — the verdict combiner in
|
|
* security.ts then falls through to 'safe' (fail-open by design).
|
|
*
|
|
* Note: TestSavantAI returns {label: 'INJECTION'|'SAFE', score: 0-1}. When
|
|
* label is 'SAFE', we return confidence=0 to the combiner. When label is
|
|
* 'INJECTION', we return the score directly.
|
|
*/
|
|
export async function scanPageContent(text: string): Promise<LayerSignal> {
|
|
if (!text || text.length === 0) {
|
|
return { layer: 'testsavant_content', confidence: 0 };
|
|
}
|
|
if (testsavantState !== 'loaded') {
|
|
return { layer: 'testsavant_content', confidence: 0, meta: { degraded: true } };
|
|
}
|
|
try {
|
|
// Normalize to plain text first — the classifier is trained on natural
|
|
// language, not HTML markup. A page with an injection buried in tag
|
|
// soup won't fire until we strip the noise.
|
|
const plain = htmlToPlainText(text);
|
|
// Character-level cap to avoid pathological memory use. The pipeline
|
|
// applies tokenizer truncation at 512 tokens (the BERT-small context
|
|
// limit — enforced via the model_max_length override in loadTestsavant)
|
|
// so the 4000-char cap is just a cheap upper bound. Real-world
|
|
// injection signals land in the first few hundred tokens anyway.
|
|
const input = plain.slice(0, 4000);
|
|
const raw = await testsavantClassifier(input);
|
|
const top = Array.isArray(raw) ? raw[0] : raw;
|
|
const label = top?.label ?? 'SAFE';
|
|
const score = Number(top?.score ?? 0);
|
|
if (label === 'INJECTION') {
|
|
return { layer: 'testsavant_content', confidence: score, meta: { label } };
|
|
}
|
|
return { layer: 'testsavant_content', confidence: 0, meta: { label, safeScore: score } };
|
|
} catch (err: any) {
|
|
testsavantState = 'failed';
|
|
testsavantLoadError = err?.message ?? String(err);
|
|
return { layer: 'testsavant_content', confidence: 0, meta: { degraded: true, error: testsavantLoadError } };
|
|
}
|
|
}
|