feat: initial open marketplace with llm-security, config-audit, ultraplan-local

This commit is contained in:
Kjell Tore Guttormsen 2026-04-06 18:47:49 +02:00
commit f93d6abdae
380 changed files with 65935 additions and 0 deletions

View file

@ -0,0 +1,374 @@
#!/usr/bin/env node
// Hook: post-mcp-verify.mjs
// Event: PostToolUse (ALL tools)
// Purpose: Monitor tool output for data leakage and indirect prompt injection.
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input, tool_output }
// - Advisory only: always exit 0. Output systemMessage via stdout to warn user.
//
// v2.3.0: Expanded from Bash-only to ALL tools.
// - Bash-specific: secret scanning, external URL detection, large MCP output
// - Universal: indirect prompt injection scanning (OWASP LLM01)
// - Short output (<100 chars) skipped for performance
// v5.0.0: MEDIUM injection patterns included in advisory output.
// v5.0.0-S4: HITL trap patterns (HIGH), sub-agent spawn (MEDIUM), NL indirection (MEDIUM),
// cognitive load trap (MEDIUM) — all via scanForInjection() from injection-patterns.mjs.
import { readFileSync, writeFileSync, appendFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs';
import { checkDescriptionDrift } from '../../scanners/lib/mcp-description-cache.mjs';
// ---------------------------------------------------------------------------
// Secret patterns — same set as pre-edit-secrets.mjs so any secret that
// slips through a write guard will at least be flagged in command output.
// Only checked for Bash tool output.
// ---------------------------------------------------------------------------
const SECRET_PATTERNS = [
{ name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ },
{ name: 'GitHub Token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/ },
{ name: 'npm Token', pattern: /npm_[A-Za-z0-9]{36}/ },
{ name: 'Private Key PEM Block', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
{ name: 'Azure Connection String', pattern: /(?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,}/ },
{ name: 'Bearer Token', pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/ },
{ name: 'Database connection string', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+/i },
{
name: 'Generic credential assignment',
pattern: /(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*['"][^'"]{8,}['"]/i,
},
];
// ---------------------------------------------------------------------------
// MCP-indicator keywords — commands that suggest MCP tool usage.
// We give extra weight to findings when the command looks MCP-related.
// Only relevant for Bash tool.
// ---------------------------------------------------------------------------
const MCP_INDICATORS = [
'mcp',
'model_context_protocol',
'claude mcp',
'npx @anthropic',
'mcp-server',
'tool_use',
'tool_result',
];
// ---------------------------------------------------------------------------
// Large data dump heuristic — output longer than this threshold (bytes) from
// an MCP-related command may indicate exfiltration or accidental bulk dump.
// Only checked for Bash tool.
// ---------------------------------------------------------------------------
const LARGE_OUTPUT_THRESHOLD = 50_000; // 50 KB
// ---------------------------------------------------------------------------
// Minimum output length for injection scanning (performance optimization).
// Short output is unlikely to contain meaningful injection payloads.
// ---------------------------------------------------------------------------
const MIN_INJECTION_SCAN_LENGTH = 100;
// ---------------------------------------------------------------------------
// Per-tool volume tracking — tracks cumulative output per MCP tool within
// a session. Warns when a single tool produces disproportionate output.
// State file: ${os.tmpdir()}/llm-security-mcp-volume-${ppid}.json
// ---------------------------------------------------------------------------
const MCP_TOOL_VOLUME_THRESHOLD = 100_000; // 100 KB from a single MCP tool
const VOLUME_STATE_FILE = join(tmpdir(), `llm-security-mcp-volume-${process.ppid}.json`);
// ---------------------------------------------------------------------------
// Unexpected external URL patterns in curl/wget invocations within output.
// Only checked for Bash tool.
// ---------------------------------------------------------------------------
const EXTERNAL_URL_PATTERN =
/(?:curl|wget)\s+(?:-[a-zA-Z]+\s+)*['"]?(https?:\/\/(?!localhost|127\.|0\.0\.0\.|::1)[^\s'"]+)/gi;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isMcpRelatedCommand(command) {
if (!command) return false;
const lower = command.toLowerCase();
return MCP_INDICATORS.some((indicator) => lower.includes(indicator));
}
function scanForSecrets(text) {
const matches = [];
for (const { name, pattern } of SECRET_PATTERNS) {
if (pattern.test(text)) {
matches.push(name);
}
}
return matches;
}
function extractExternalUrls(text) {
const urls = [];
let match;
const re = new RegExp(EXTERNAL_URL_PATTERN.source, EXTERNAL_URL_PATTERN.flags);
while ((match = re.exec(text)) !== null) {
urls.push(match[1]);
}
return [...new Set(urls)]; // deduplicate
}
function emitAdvisory(message) {
process.stdout.write(
JSON.stringify({ systemMessage: message })
);
}
/**
* Format a tool identifier for advisory messages.
* For Bash: includes the command. For other tools: includes tool name and relevant input.
*/
function formatToolContext(toolName, toolInput) {
if (toolName === 'Bash') {
const cmd = toolInput?.command ?? '';
return `Command: ${cmd.slice(0, 150)}${cmd.length > 150 ? '...' : ''}`;
}
if (toolName === 'Read') {
const target = toolInput?.file_path ?? '';
return `Tool: Read, file: ${target.slice(0, 150)}`;
}
if (toolName === 'WebFetch') {
const target = toolInput?.url ?? '';
return `Tool: WebFetch, url: ${target.slice(0, 150)}`;
}
// MCP tools often have descriptive names
if (toolName?.startsWith('mcp__')) {
return `MCP tool: ${toolName}`;
}
return `Tool: ${toolName}`;
}
// ---------------------------------------------------------------------------
// Per-tool MCP volume state
// ---------------------------------------------------------------------------
/**
* Load per-tool volume state.
* @returns {{ volumes: Record<string, number>, warned: Record<string, boolean> }}
*/
function loadVolumeState() {
try {
if (existsSync(VOLUME_STATE_FILE)) {
return JSON.parse(readFileSync(VOLUME_STATE_FILE, 'utf-8'));
}
} catch { /* ignore */ }
return { volumes: {}, warned: {} };
}
/**
* Save per-tool volume state.
* @param {{ volumes: Record<string, number>, warned: Record<string, boolean> }} state
*/
function saveVolumeState(state) {
try {
writeFileSync(VOLUME_STATE_FILE, JSON.stringify(state), 'utf-8');
} catch { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
// Cannot parse stdin — exit silently.
process.exit(0);
}
const toolName = input?.tool_name ?? '';
const toolInput = input?.tool_input ?? {};
const toolOutput = input?.tool_output ?? '';
const command = toolInput?.command ?? '';
// Convert tool_output to string if it isn't already (some hooks pass objects)
const outputText = typeof toolOutput === 'string'
? toolOutput
: JSON.stringify(toolOutput);
if (!outputText.trim()) {
process.exit(0);
}
const advisories = [];
const isBash = toolName === 'Bash';
// =========================================================================
// Bash-specific checks: secrets, external URLs, large MCP output
// These checks are only relevant for shell command output.
// =========================================================================
if (isBash) {
const isMcp = isMcpRelatedCommand(command);
const secretHits = scanForSecrets(outputText);
const externalUrls = extractExternalUrls(outputText);
const isLargeOutput = outputText.length > LARGE_OUTPUT_THRESHOLD;
// --- Secret detection in output ---
if (secretHits.length > 0) {
advisories.push(
`Potential secret(s) detected in command output:\n` +
secretHits.map((n) => ` - ${n}`).join('\n') + '\n' +
` Review the output above before sharing logs, screenshots, or copying to external systems.\n` +
` Rotate any exposed credentials immediately.`
);
}
// --- Unexpected external URLs (only flag when in MCP context or multiple hits) ---
if (externalUrls.length > 0 && (isMcp || externalUrls.length > 2)) {
advisories.push(
`External URL(s) accessed via curl/wget in command output:\n` +
externalUrls.slice(0, 5).map((u) => ` - ${u}`).join('\n') +
(externalUrls.length > 5 ? `\n ... and ${externalUrls.length - 5} more` : '') + '\n' +
` Verify these requests are expected and that no sensitive data was sent.`
);
}
// --- Large output from MCP-related command ---
if (isMcp && isLargeOutput) {
const kb = Math.round(outputText.length / 1024);
advisories.push(
`Large output (${kb} KB) from an MCP-related command.\n` +
` Unexpectedly large MCP responses may indicate bulk data retrieval or exfiltration.\n` +
` ${formatToolContext(toolName, toolInput)}`
);
}
}
// =========================================================================
// Universal check: indirect prompt injection in tool output (LLM01)
// Runs for ALL tools. External content fetched by any tool may contain
// injection payloads targeting the model.
// Skip short output for performance.
// v5.0.0: Now includes MEDIUM patterns in advisory.
// =========================================================================
if (outputText.length >= MIN_INJECTION_SCAN_LENGTH) {
const scanSlice = outputText.slice(0, 100_000); // first 100 KB
const injection = scanForInjection(scanSlice);
if (injection.critical.length > 0 || injection.high.length > 0 || injection.medium.length > 0) {
const lines = [];
if (injection.critical.length > 0) {
lines.push(` Critical injection patterns:`);
for (const c of injection.critical) lines.push(` - ${c}`);
}
if (injection.high.length > 0) {
lines.push(` Manipulation signals:`);
for (const h of injection.high) lines.push(` - ${h}`);
}
if (injection.medium.length > 0) {
// When critical/high are present, just append count. When medium-only, list them.
if (injection.critical.length > 0 || injection.high.length > 0) {
lines.push(` Additionally, ${injection.medium.length} lower-confidence signal(s) (MEDIUM).`);
} else {
lines.push(` Obfuscation/manipulation signals (MEDIUM):`);
for (const m of injection.medium) lines.push(` - ${m}`);
}
}
const severity = injection.critical.length > 0 ? 'CRITICAL' : injection.high.length > 0 ? 'HIGH' : 'MEDIUM';
advisories.push(
`Indirect prompt injection detected in tool output — ${severity} (OWASP LLM01).\n` +
lines.join('\n') + '\n' +
` External content may be attempting to manipulate the model.\n` +
` ${formatToolContext(toolName, toolInput)}`
);
}
}
// =========================================================================
// HTML content check: CSS-hidden content detection (AI Agent Traps)
// WebFetch and Read may return HTML with visually hidden elements that
// contain adversarial instructions. Agents parse these; humans do not.
// =========================================================================
const isHtmlSource = toolName === 'WebFetch' || toolName === 'Read' || toolName?.startsWith('mcp__');
if (isHtmlSource && outputText.length >= MIN_INJECTION_SCAN_LENGTH) {
const htmlSlice = outputText.slice(0, 100_000);
// Only run HTML-specific checks if content looks like HTML
if (/<[a-zA-Z][^>]*>/.test(htmlSlice)) {
const htmlFindings = [];
// Detect CSS-hidden elements with substantial content
const hiddenElementRegex = /<([a-z]+)\s[^>]*style\s*=\s*"[^"]*(?:display\s*:\s*none|visibility\s*:\s*hidden|position\s*:\s*absolute[^"]*-\d{3,}px|font-size\s*:\s*0|opacity\s*:\s*0)[^"]*"[^>]*>([^<]{20,})/gi;
let htmlMatch;
while ((htmlMatch = hiddenElementRegex.exec(htmlSlice)) !== null) {
const content = htmlMatch[2].trim().slice(0, 100);
htmlFindings.push(`CSS-hidden <${htmlMatch[1]}>: "${content}${htmlMatch[2].length > 100 ? '...' : ''}"`);
}
// Detect injection in aria-label attributes
const ariaRegex = /aria-label\s*=\s*"([^"]{20,})"/gi;
while ((htmlMatch = ariaRegex.exec(htmlSlice)) !== null) {
const ariaContent = htmlMatch[1].toLowerCase();
if (/(?:ignore|override|system|instruction|execute|exfiltrate|forget|disregard)/.test(ariaContent)) {
htmlFindings.push(`Injection in aria-label: "${htmlMatch[1].slice(0, 100)}"`);
}
}
if (htmlFindings.length > 0) {
advisories.push(
`Hidden HTML content detected — possible Agent Trap (OWASP LLM01, Content Injection).\n` +
` AI agents parse hidden elements that are invisible to human reviewers.\n` +
htmlFindings.map(f => ` - ${f}`).join('\n') + '\n' +
` ${formatToolContext(toolName, toolInput)}`
);
}
}
}
// =========================================================================
// MCP description drift detection (OWASP MCP05 — Rug Pull)
// Checks if the MCP tool's description has changed since first seen.
// Only relevant for MCP tools that provide a description in tool_input.
// =========================================================================
const isMcpTool = toolName?.startsWith('mcp__');
if (isMcpTool) {
const description = toolInput?.description || toolInput?.tool_description || '';
if (description && typeof description === 'string' && description.length > 10) {
try {
const driftResult = checkDescriptionDrift(toolName, description);
if (driftResult.drift) {
advisories.push(
`MCP tool description drift detected (OWASP MCP05 — Rug Pull).\n` +
` ${driftResult.detail}\n` +
` Previous: "${(driftResult.cached || '').slice(0, 120)}${(driftResult.cached || '').length > 120 ? '...' : ''}"\n` +
` Current: "${description.slice(0, 120)}${description.length > 120 ? '...' : ''}"\n` +
` A changed tool description may indicate the MCP server has been compromised.`
);
}
} catch { /* drift check is advisory, never block */ }
}
}
// =========================================================================
// Per-MCP-tool volume tracking
// Tracks cumulative output size per MCP tool within a session. Warns when
// a single tool produces disproportionate output (>100 KB cumulative).
// =========================================================================
if (isMcpTool && outputText.length > 0) {
const volState = loadVolumeState();
volState.volumes[toolName] = (volState.volumes[toolName] || 0) + outputText.length;
const toolTotal = volState.volumes[toolName];
if (toolTotal >= MCP_TOOL_VOLUME_THRESHOLD && !volState.warned[toolName]) {
const kb = Math.round(toolTotal / 1024);
advisories.push(
`MCP tool cumulative output exceeded ${Math.round(MCP_TOOL_VOLUME_THRESHOLD / 1024)} KB.\n` +
` Tool: ${toolName}\n` +
` Cumulative output this session: ~${kb} KB\n` +
` High per-tool volume may indicate bulk data harvesting (OWASP ASI02, MCP03).`
);
volState.warned[toolName] = true;
}
saveVolumeState(volState);
}
// Emit combined advisory if anything was flagged
if (advisories.length > 0) {
const header = 'SECURITY ADVISORY (post-mcp-verify): Potential data leakage detected.';
const body = advisories.map((a, i) => `[${i + 1}] ${a}`).join('\n\n');
emitAdvisory(`${header}\n\n${body}`);
}
// PostToolUse hooks are always advisory — never block.
process.exit(0);

View file

@ -0,0 +1,889 @@
#!/usr/bin/env node
// Hook: post-session-guard.mjs
// Event: PostToolUse (ALL tools)
// Purpose: Runtime lethal trifecta detection — monitors tool call sequences
// and warns when untrusted input + sensitive data access + exfiltration
// sink all appear within a sliding window.
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input, tool_output }
// - Advisory only: always exit 0. Output systemMessage via stdout to warn.
// - State persisted in ${os.tmpdir()}/llm-security-session-${ppid}.jsonl
//
// Rule of Two (Meta, Oct 2025):
// Of 3 capabilities A (untrusted input), B (sensitive data), C (state change/exfil),
// an agent should NEVER hold all 3 simultaneously. Env var LLM_SECURITY_TRIFECTA_MODE
// controls enforcement: warn (default), block (exit 2 for high-confidence trifecta), off.
//
// Long-horizon monitoring (OpenAI Atlas, Dec 2025):
// 100-call window alongside 20-call for slow-burn trifecta detection and
// behavioral drift via Jensen-Shannon divergence on tool distributions.
//
// Sub-agent delegation tracking (DeepMind Agent Traps kat. 4, v5.0 S4):
// Task/Agent tools classified as 'delegation'. Escalation-after-input advisory
// when delegation occurs within 5 calls of an input_source (untrusted content
// may be influencing sub-agent spawning decisions).
//
// CaMeL-inspired data flow tagging (DeepMind CaMeL, v5.0 S6):
// Lightweight data provenance tracking. On tool output: hash first 200 chars as
// data tag. On next tool input: check substring match against prior tags. Match =
// "data flow link". Trifecta with linked flows = elevated severity.
//
// Trifecta concept (Willison / Invariant Labs):
// 1. Agent exposed to UNTRUSTED INPUT (prompt injection surface)
// 2. Agent has access to SENSITIVE DATA via tools
// 3. An EXFILTRATION SINK exists (HTTP POST, scp, etc.)
//
// OWASP: ASI01 (Excessive Agency), ASI02 (Data Leakage), LLM01 (Prompt Injection)
import { readFileSync, appendFileSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { extractMcpServer } from '../../scanners/lib/mcp-description-cache.mjs';
import { jensenShannonDivergence, buildDistribution } from '../../scanners/lib/distribution-stats.mjs';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const WINDOW_SIZE = 20;
const STATE_PREFIX = 'llm-security-session-';
const STATE_DIR = tmpdir();
const CLEANUP_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
// Long-horizon monitoring (OpenAI Atlas, Dec 2025)
const LONG_HORIZON_WINDOW = 100;
const SLOW_BURN_MIN_SPREAD = 50;
const DRIFT_THRESHOLD = 0.25;
const DRIFT_SAMPLE_SIZE = 20;
// Sub-agent delegation tracking (DeepMind Agent Traps kat. 4, v5.0 S4)
const DELEGATION_ESCALATION_WINDOW = 5; // calls after input_source
// Rule of Two enforcement mode: block | warn | off (default: warn)
const TRIFECTA_MODE = (process.env.LLM_SECURITY_TRIFECTA_MODE || 'warn').toLowerCase();
// Volume tracking thresholds (cumulative bytes per session)
const VOLUME_THRESHOLDS = [
{ bytes: 1_000_000, label: '1 MB', severity: 'HIGH' },
{ bytes: 500_000, label: '500 KB', severity: 'MEDIUM' },
{ bytes: 100_000, label: '100 KB', severity: 'LOW' },
];
// ---------------------------------------------------------------------------
// Sensitive path patterns (for data_access classification of Read/Bash)
// ---------------------------------------------------------------------------
const SENSITIVE_PATH_PATTERNS = [
/\.env(?:\.|$)/i,
/\.ssh\//i,
/\.aws\//i,
/\.gnupg\//i,
/credentials/i,
/secrets?[./]/i,
/tokens?[./]/i,
/password/i,
/keychain/i,
/\.npmrc/i,
/\.pypirc/i,
/id_rsa/i,
/id_ed25519/i,
/authorized_keys/i,
/\.netrc/i,
/\.pgpass/i,
];
// ---------------------------------------------------------------------------
// Bash command patterns
// ---------------------------------------------------------------------------
const BASH_EXFIL_PATTERNS = [
/\bcurl\b[^|]*(?:-X\s*(?:POST|PUT|PATCH)\b|-d\s|--data\b|--data-\w+\b|-F\s|--form\b)/i,
/\bwget\b[^|]*--post/i,
/\bnc\s+(?:-[a-zA-Z]*\s+)*\S+\s+\d/i, // nc host port
/\bsendmail\b/i,
/\bscp\s/i,
/\brsync\b[^|]*[^/]\S+:/i, // rsync to remote (user@host:)
/\bgit\s+push\b/i,
/\bsftp\b/i,
];
const BASH_INPUT_PATTERNS = [
/\bcurl\b/i, // curl without POST indicators = downloading
/\bwget\b/i, // wget without --post = downloading
];
const BASH_DATA_CMD_PATTERNS = [
/\b(?:cat|head|tail|less|more|bat)\s/i,
];
// ---------------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------------
/**
* Classify a tool call into trifecta leg(s).
* @param {string} toolName
* @param {object} toolInput
* @returns {{ classes: string[], detail: string }}
*/
function classifyToolCall(toolName, toolInput) {
// --- WebFetch / WebSearch: always input_source ---
if (toolName === 'WebFetch' || toolName === 'WebSearch') {
const target = toolInput?.url || toolInput?.query || '';
return { classes: ['input_source'], detail: target.slice(0, 80) };
}
// --- MCP tools: untrusted external input ---
if (toolName?.startsWith('mcp__')) {
return { classes: ['input_source'], detail: toolName };
}
// --- Task / Agent: delegation (DeepMind Agent Traps kat. 4, v5.0 S4) ---
if (toolName === 'Task' || toolName === 'Agent') {
const desc = toolInput?.description || toolInput?.prompt || '';
return { classes: ['delegation'], detail: desc.slice(0, 80) };
}
// --- Read: data_access (sensitive path = stronger signal, but all reads count) ---
if (toolName === 'Read') {
const filePath = toolInput?.file_path || '';
const isSensitive = SENSITIVE_PATH_PATTERNS.some(p => p.test(filePath));
return {
classes: ['data_access'],
detail: `${isSensitive ? '[SENSITIVE] ' : ''}${filePath.slice(-60)}`,
};
}
// --- Grep / Glob: data_access ---
if (toolName === 'Grep' || toolName === 'Glob') {
const target = toolInput?.pattern || toolInput?.path || '';
return { classes: ['data_access'], detail: target.slice(0, 60) };
}
// --- Bash: can be multiple classes depending on command ---
if (toolName === 'Bash') {
return classifyBashCommand(toolInput?.command || '');
}
// --- Everything else: neutral ---
return { classes: ['neutral'], detail: '' };
}
/**
* Classify a Bash command. Can return multiple classes.
* @param {string} command
* @returns {{ classes: string[], detail: string }}
*/
function classifyBashCommand(command) {
const classes = [];
const detail = command.slice(0, 80);
// Check exfil first (highest priority)
if (BASH_EXFIL_PATTERNS.some(p => p.test(command))) {
classes.push('exfil_sink');
}
// Check data access: command reads files AND path looks sensitive
if (BASH_DATA_CMD_PATTERNS.some(p => p.test(command))) {
if (SENSITIVE_PATH_PATTERNS.some(p => p.test(command))) {
classes.push('data_access');
}
}
// Check input source: curl/wget without POST = downloading content
// Only add if not already classified as exfil (avoid double-counting curl POST)
if (!classes.includes('exfil_sink') && BASH_INPUT_PATTERNS.some(p => p.test(command))) {
classes.push('input_source');
}
if (classes.length === 0) {
classes.push('neutral');
}
return { classes, detail };
}
// ---------------------------------------------------------------------------
// State management
// ---------------------------------------------------------------------------
/**
* Get the state file path for this session.
* @returns {string}
*/
function getStateFilePath() {
return join(STATE_DIR, `${STATE_PREFIX}${process.ppid}.jsonl`);
}
/**
* Append a tool call entry to the state file.
* @param {string} stateFile
* @param {object} entry
*/
function appendEntry(stateFile, entry) {
appendFileSync(stateFile, JSON.stringify(entry) + '\n', 'utf-8');
}
/**
* Read the last N entries from the state file.
* @param {string} stateFile
* @param {number} n
* @returns {object[]}
*/
function readLastEntries(stateFile, n) {
if (!existsSync(stateFile)) return [];
try {
const content = readFileSync(stateFile, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
const tail = lines.slice(-n);
const entries = [];
for (const line of tail) {
try { entries.push(JSON.parse(line)); } catch { /* skip malformed */ }
}
return entries;
} catch {
return [];
}
}
/**
* Clean up state files older than CLEANUP_MAX_AGE_MS.
* Only called on first invocation per session (when state file doesn't exist yet).
*/
function cleanupOldStateFiles() {
try {
const now = Date.now();
const files = readdirSync(STATE_DIR);
for (const file of files) {
if (!file.startsWith(STATE_PREFIX) || !file.endsWith('.jsonl')) continue;
const fullPath = join(STATE_DIR, file);
try {
const stat = statSync(fullPath);
if (now - stat.mtimeMs > CLEANUP_MAX_AGE_MS) {
unlinkSync(fullPath);
}
} catch { /* ignore per-file errors */ }
}
} catch { /* ignore cleanup errors entirely */ }
}
// ---------------------------------------------------------------------------
// Trifecta detection
// ---------------------------------------------------------------------------
/**
* Check if all 3 trifecta legs are present in the window.
* @param {object[]} entries
* @returns {{ detected: boolean, evidence: { input: string[], access: string[], exfil: string[] } }}
*/
function checkTrifecta(entries) {
const evidence = { input: [], access: [], exfil: [] };
for (const entry of entries) {
if (entry.type === 'warning') continue; // skip warning markers
const classes = entry.classes || [];
for (const cls of classes) {
if (cls === 'input_source') evidence.input.push(entry.detail || entry.tool);
if (cls === 'data_access') evidence.access.push(entry.detail || entry.tool);
if (cls === 'exfil_sink') evidence.exfil.push(entry.detail || entry.tool);
}
}
return {
detected: evidence.input.length > 0 && evidence.access.length > 0 && evidence.exfil.length > 0,
evidence,
};
}
/**
* Check if a warning was already emitted in the current window.
* @param {object[]} entries
* @returns {boolean}
*/
function hasRecentWarning(entries) {
return entries.some(e => e.type === 'warning');
}
/**
* Check if the trifecta is MCP-concentrated: all 3 legs originate from tools
* on the same MCP server. This is a stronger signal a single compromised
* server providing input, accessing data, AND exfiltrating.
* @param {object[]} entries
* @returns {{ concentrated: boolean, server: string|null }}
*/
function checkMcpConcentration(entries) {
// Collect MCP servers per trifecta leg
const serversByLeg = { input: new Set(), access: new Set(), exfil: new Set() };
for (const entry of entries) {
if (entry.type === 'warning') continue;
const server = extractMcpServer(entry.tool);
if (!server) continue;
const classes = entry.classes || [];
for (const cls of classes) {
if (cls === 'input_source') serversByLeg.input.add(server);
if (cls === 'data_access') serversByLeg.access.add(server);
if (cls === 'exfil_sink') serversByLeg.exfil.add(server);
}
}
// Find a server present in all 3 legs
for (const server of serversByLeg.input) {
if (serversByLeg.access.has(server) && serversByLeg.exfil.has(server)) {
return { concentrated: true, server };
}
}
return { concentrated: false, server: null };
}
/**
* Check if the trifecta involves sensitive path access + exfiltration.
* This is a high-confidence signal: data from .env/.ssh/.aws etc. being sent out.
* @param {object[]} entries
* @returns {boolean}
*/
function checkSensitiveExfil(entries) {
let hasSensitiveAccess = false;
let hasExfil = false;
for (const entry of entries) {
if (entry.type === 'warning') continue;
const classes = entry.classes || [];
const detail = entry.detail || '';
if (classes.includes('data_access') && detail.startsWith('[SENSITIVE]')) {
hasSensitiveAccess = true;
}
if (classes.includes('exfil_sink')) {
hasExfil = true;
}
}
return hasSensitiveAccess && hasExfil;
}
/**
* Compute cumulative data volume from entries with outputSize.
* @param {object[]} allEntries - All entries (not just window)
* @returns {number} Total bytes
*/
function computeCumulativeVolume(allEntries) {
let total = 0;
for (const entry of allEntries) {
if (entry.type === 'warning' || entry.type === 'volume_warning') continue;
total += entry.outputSize || 0;
}
return total;
}
/**
* Check if a volume warning at a given threshold was already emitted.
* @param {object[]} entries
* @param {number} thresholdBytes
* @returns {boolean}
*/
function hasVolumeWarning(entries, thresholdBytes) {
return entries.some(e => e.type === 'volume_warning' && e.threshold === thresholdBytes);
}
/**
* Format the volume warning message.
* @param {number} totalBytes
* @param {string} thresholdLabel
* @param {string} severity
* @returns {string}
*/
function formatVolumeWarning(totalBytes, thresholdLabel, severity) {
const kb = Math.round(totalBytes / 1024);
return (
`SECURITY ADVISORY (session-guard): Cumulative MCP data volume exceeded ${thresholdLabel} [${severity}].\n\n` +
`This session has received ~${kb} KB of tool output data.\n` +
'High cumulative volume may indicate bulk data harvesting or exfiltration staging (OWASP ASI02).\n' +
'Review whether the volume of data being processed is proportional to the task.'
);
}
/**
* Format the trifecta warning message.
* Uses Rule of Two terminology (Meta, Oct 2025): A=untrusted input, B=sensitive data, C=state change/exfil.
* @param {{ input: string[], access: string[], exfil: string[] }} evidence
* @param {{ concentrated: boolean, server: string|null }} [mcpInfo]
* @param {boolean} [isSensitiveExfil]
* @returns {string}
*/
function formatWarning(evidence, mcpInfo, isSensitiveExfil) {
const inputEx = evidence.input.slice(-2).map(e => ` - ${e}`).join('\n');
const accessEx = evidence.access.slice(-2).map(e => ` - ${e}`).join('\n');
const exfilEx = evidence.exfil.slice(-2).map(e => ` - ${e}`).join('\n');
const mcpLine = mcpInfo?.concentrated
? `\nRULE OF TWO VIOLATION: MCP-CONCENTRATED — All 3 legs trace to server "${mcpInfo.server}" (elevated severity).\n`
: '';
const sensitiveLine = isSensitiveExfil
? '\nRULE OF TWO VIOLATION: SENSITIVE DATA + EXFILTRATION — Sensitive paths accessed and exfil sink present.\n'
: '';
return (
'SECURITY ADVISORY (session-guard): Rule of Two violation — potential lethal trifecta detected.\n\n' +
'Within the last 20 tool calls, this session holds all 3 capabilities simultaneously:\n' +
' [A] Untrusted external input (prompt injection surface):\n' + inputEx + '\n' +
' [B] Sensitive data access:\n' + accessEx + '\n' +
' [C] Exfiltration-capable tool (state change):\n' + exfilEx + '\n' +
mcpLine + sensitiveLine + '\n' +
'Rule of Two (Meta, Oct 2025): An agent should never hold A+B+C simultaneously.\n' +
'This combination enables prompt injection -> data theft chains (OWASP ASI01, ASI02, LLM01).\n' +
'Review recent tool calls for unexpected behavior.'
);
}
// ---------------------------------------------------------------------------
// Sub-agent delegation tracking (DeepMind Agent Traps kat. 4, v5.0 S4)
// ---------------------------------------------------------------------------
/**
* Check for escalation-after-input: delegation within DELEGATION_ESCALATION_WINDOW
* calls of an input_source. Untrusted content consumed shortly before spawning a
* sub-agent may indicate the model is being manipulated into delegating dangerous work.
* @param {object[]} entries recent window (20-call)
* @param {{ classes: string[] }} currentEntry the entry just appended
* @returns {{ detected: boolean, inputDetail: string }}
*/
function checkEscalationAfterInput(entries, currentEntry) {
if (!currentEntry.classes.includes('delegation')) {
return { detected: false, inputDetail: '' };
}
// Walk backwards through the last DELEGATION_ESCALATION_WINDOW entries
// looking for an input_source
const toolEntries = entries.filter(e => !e.type);
const recentN = toolEntries.slice(-(DELEGATION_ESCALATION_WINDOW + 1), -1); // exclude current
for (const entry of recentN) {
if ((entry.classes || []).includes('input_source')) {
return { detected: true, inputDetail: entry.detail || entry.tool || 'unknown' };
}
}
return { detected: false, inputDetail: '' };
}
/**
* Check if an escalation-after-input warning was already emitted.
* @param {object[]} entries
* @returns {boolean}
*/
function hasEscalationWarning(entries) {
return entries.some(e => e.type === 'escalation_warning');
}
/**
* Format the escalation-after-input warning.
* @param {string} delegationDetail what the delegation was for
* @param {string} inputDetail what input source preceded it
* @returns {string}
*/
function formatEscalationWarning(delegationDetail, inputDetail) {
return (
'SECURITY ADVISORY (session-guard): Escalation-after-input detected [MEDIUM] — ' +
'sub-agent delegation shortly after untrusted input.\n\n' +
`A Task/Agent delegation occurred within ${DELEGATION_ESCALATION_WINDOW} calls of untrusted input:\n` +
` Input source: ${inputDetail}\n` +
` Delegation: ${delegationDetail}\n\n` +
'Untrusted content (web pages, MCP tool output) may be influencing the model\n' +
'to spawn sub-agents with capabilities beyond the original task scope.\n' +
'This is a known attack vector (DeepMind AI Agent Traps, Category 4).\n' +
'Review whether this delegation is expected and appropriately scoped.'
);
}
// ---------------------------------------------------------------------------
// Long-horizon monitoring (100-call window) — OpenAI Atlas, Dec 2025
// ---------------------------------------------------------------------------
/**
* Filter entries to only tool calls (exclude warning/marker entries).
* @param {object[]} entries
* @returns {object[]}
*/
function filterToolEntries(entries) {
return entries.filter(e => !e.type);
}
/**
* Check for slow-burn trifecta: all 3 legs present but spread over >50 calls.
* Catches multi-step injection chains that pace actions to avoid short-window detection.
* @param {object[]} entries - Long-horizon window entries
* @returns {{ detected: boolean, spread: number }}
*/
function checkSlowBurnTrifecta(entries) {
const toolEntries = filterToolEntries(entries);
let firstInput = -1, firstAccess = -1, firstExfil = -1;
let lastInput = -1, lastAccess = -1, lastExfil = -1;
for (let i = 0; i < toolEntries.length; i++) {
for (const cls of toolEntries[i].classes || []) {
if (cls === 'input_source') {
if (firstInput === -1) firstInput = i;
lastInput = i;
}
if (cls === 'data_access') {
if (firstAccess === -1) firstAccess = i;
lastAccess = i;
}
if (cls === 'exfil_sink') {
if (firstExfil === -1) firstExfil = i;
lastExfil = i;
}
}
}
if (firstInput === -1 || firstAccess === -1 || firstExfil === -1) {
return { detected: false, spread: 0 };
}
const earliestFirst = Math.min(firstInput, firstAccess, firstExfil);
const latestLast = Math.max(lastInput, lastAccess, lastExfil);
const spread = latestLast - earliestFirst;
return { detected: spread > SLOW_BURN_MIN_SPREAD, spread };
}
/**
* @param {object[]} entries
* @returns {boolean}
*/
function hasSlowBurnWarning(entries) {
return entries.some(e => e.type === 'slow_burn_warning');
}
/**
* Detect behavioral drift: tool distribution shift in first vs last DRIFT_SAMPLE_SIZE calls.
* @param {object[]} entries
* @returns {{ drifted: boolean, jsd: number, firstTools: string[], lastTools: string[] }}
*/
function checkBehavioralDrift(entries) {
const toolEntries = filterToolEntries(entries);
if (toolEntries.length < 2 * DRIFT_SAMPLE_SIZE) {
return { drifted: false, jsd: 0, firstTools: [], lastTools: [] };
}
const firstTools = toolEntries.slice(0, DRIFT_SAMPLE_SIZE).map(e => e.tool);
const lastTools = toolEntries.slice(-DRIFT_SAMPLE_SIZE).map(e => e.tool);
const P = buildDistribution(firstTools);
const Q = buildDistribution(lastTools);
const jsd = jensenShannonDivergence(P, Q);
return { drifted: jsd > DRIFT_THRESHOLD, jsd, firstTools, lastTools };
}
/**
* @param {object[]} entries
* @returns {boolean}
*/
function hasDriftWarning(entries) {
return entries.some(e => e.type === 'drift_warning');
}
/**
* Get top N most frequent items from an array, formatted as "name(count)".
* @param {string[]} items
* @param {number} n
* @returns {string}
*/
function topN(items, n) {
const counts = new Map();
for (const item of items) counts.set(item, (counts.get(item) || 0) + 1);
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, n)
.map(([name, count]) => `${name}(${count})`)
.join(', ');
}
/**
* Format the slow-burn trifecta warning message.
* @param {number} spread
* @returns {string}
*/
function formatSlowBurnWarning(spread) {
return (
'SECURITY ADVISORY (session-guard): Slow-burn trifecta detected [MEDIUM] — ' +
`Rule of Two legs spread over ${spread} calls.\n\n` +
'Within the last 100 tool calls, all 3 capabilities appeared but spread across a wide range:\n' +
' [A] Untrusted external input (prompt injection surface)\n' +
' [B] Sensitive data access\n' +
' [C] Exfiltration-capable tool (state change)\n\n' +
'This pattern may indicate a multi-step prompt injection chain (OpenAI Atlas, Dec 2025).\n' +
'Wide spread across calls makes detection harder with short-window monitoring.'
);
}
/**
* Format the behavioral drift warning message.
* @param {number} jsd
* @param {string[]} firstTools
* @param {string[]} lastTools
* @returns {string}
*/
function formatDriftWarning(jsd, firstTools, lastTools) {
return (
'SECURITY ADVISORY (session-guard): Behavioral drift detected [MEDIUM] — tool usage shift.\n\n' +
`Jensen-Shannon divergence: ${jsd.toFixed(3)} (threshold: ${DRIFT_THRESHOLD})\n` +
`First ${DRIFT_SAMPLE_SIZE} calls: ${topN(firstTools, 3)}\n` +
`Last ${DRIFT_SAMPLE_SIZE} calls: ${topN(lastTools, 3)}\n\n` +
'A significant shift in tool usage patterns may indicate session hijacking or prompt injection\n' +
"changing the agent's behavior over time (OpenAI Atlas, Dec 2025)."
);
}
// ---------------------------------------------------------------------------
// CaMeL-inspired data flow tagging (DeepMind CaMeL, v5.0 S6)
// ---------------------------------------------------------------------------
/**
* Compute a short data tag from tool output (first 200 chars, SHA-256 truncated to 16 hex).
* Used for lightweight data provenance tracking.
* @param {string} text - tool output text
* @returns {string} 16-char hex hash
*/
function computeDataTag(text) {
const sample = text.slice(0, 200);
return createHash('sha256').update(sample).digest('hex').slice(0, 16);
}
/**
* Extract a string representation of tool input for data flow matching.
* @param {object} toolInput
* @returns {string}
*/
function extractInputText(toolInput) {
if (!toolInput || typeof toolInput !== 'object') return '';
// Collect all string values from the input object
const parts = [];
for (const val of Object.values(toolInput)) {
if (typeof val === 'string') parts.push(val);
else if (typeof val === 'object') parts.push(JSON.stringify(val));
}
return parts.join(' ');
}
/**
* Check if the current tool input contains data that matches a previous output's tag.
* Matches by checking if the first 200 chars of any previous output hash matches
* a stored tag, AND the current input contains a substring from previous output.
* For efficiency, uses dataTag hashes and inputSnippet matching.
* @param {object[]} entries - recent state entries
* @param {string} currentInputText - stringified current tool input
* @returns {{ linked: boolean, sourceEntries: object[] }}
*/
function checkDataFlowLink(entries, currentInputText) {
if (!currentInputText || currentInputText.length < 20) {
return { linked: false, sourceEntries: [] };
}
const sourceEntries = [];
// Check if any previous entry's data tag matches content in current input
for (const entry of entries) {
if (entry.type || !entry.dataTag) continue;
// Check if the input text contains a meaningful snippet from the output
// We store inputSnippet from previous entries for cross-reference
if (entry.outputSnippet && currentInputText.includes(entry.outputSnippet)) {
sourceEntries.push(entry);
}
}
return { linked: sourceEntries.length > 0, sourceEntries };
}
/**
* Check if a data flow warning was already emitted.
* @param {object[]} entries
* @returns {boolean}
*/
function hasDataFlowWarning(entries) {
return entries.some(e => e.type === 'data_flow_warning');
}
/**
* Format the data flow linked trifecta warning.
* @param {{ input: string[], access: string[], exfil: string[] }} evidence
* @param {object[]} sourceEntries
* @returns {string}
*/
function formatDataFlowWarning(evidence, sourceEntries) {
const sources = sourceEntries.slice(0, 3).map(e =>
` - ${e.tool}${e.detail || 'unknown'}`
).join('\n');
return (
'SECURITY ADVISORY (session-guard): Data flow linked trifecta [HIGH] — ' +
'CaMeL-style provenance tracking detected data flow chain.\n\n' +
'Tool output from an untrusted source appears to flow into subsequent tool inputs,\n' +
'creating a traceable data flow chain across the trifecta:\n' +
` Data flow sources:\n${sources}\n\n` +
'This elevates the trifecta severity: data is not just co-located in the session,\n' +
'but actively flowing between tools in a potential injection chain (DeepMind CaMeL).'
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
process.exit(0);
}
const toolName = input?.tool_name ?? '';
const toolInput = input?.tool_input ?? {};
const toolOutput = input?.tool_output ?? '';
if (!toolName) {
process.exit(0);
}
// Off mode: skip all detection
if (TRIFECTA_MODE === 'off') {
process.exit(0);
}
// Compute output size for volume tracking
const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput);
const outputSize = Buffer.byteLength(outputText, 'utf-8');
// Classify the current tool call
const { classes, detail } = classifyToolCall(toolName, toolInput);
// State file management
const stateFile = getStateFilePath();
const isFirstCall = !existsSync(stateFile);
// Cleanup old state files on first call per session
if (isFirstCall) {
cleanupOldStateFiles();
}
// Compute data tag for CaMeL-style flow tracking (v5.0 S6)
const dataTag = outputText.length >= 20 ? computeDataTag(outputText) : null;
// Store a short snippet from output for data flow matching (first 50 non-whitespace chars)
const outputSnippet = outputText.length >= 50
? outputText.trim().slice(0, 50)
: null;
// Append current entry (with outputSize for volume tracking, dataTag for CaMeL)
const entry = {
ts: Date.now(),
tool: toolName,
classes,
detail,
outputSize,
...(dataTag ? { dataTag } : {}),
...(outputSnippet ? { outputSnippet } : {}),
};
appendEntry(stateFile, entry);
const messages = [];
// --- Trifecta detection (skip for neutral-only and delegation-only calls) ---
if (!(classes.length === 1 && (classes[0] === 'neutral' || classes[0] === 'delegation'))) {
const window = readLastEntries(stateFile, WINDOW_SIZE);
const { detected, evidence } = checkTrifecta(window);
if (detected && !hasRecentWarning(window)) {
const mcpInfo = checkMcpConcentration(window);
const sensitiveExfil = checkSensitiveExfil(window);
messages.push(formatWarning(evidence, mcpInfo, sensitiveExfil));
appendEntry(stateFile, { type: 'warning', ts: Date.now() });
// --- Rule of Two: Block mode ---
// Block for high-confidence trifecta: MCP-concentrated OR sensitive path + exfil
if (TRIFECTA_MODE === 'block' && (mcpInfo.concentrated || sensitiveExfil)) {
process.stderr.write(
'BLOCKED: Rule of Two violation — high-confidence lethal trifecta detected.\n' +
(mcpInfo.concentrated
? ` MCP-concentrated: all 3 legs via server "${mcpInfo.server}"\n`
: ' Sensitive data access combined with exfiltration sink\n') +
' Set LLM_SECURITY_TRIFECTA_MODE=warn to downgrade to advisory.\n'
);
process.stdout.write(JSON.stringify({ decision: 'block' }));
process.exit(2);
}
}
}
// --- Escalation-after-input detection (delegation within 5 calls of input_source) ---
if (classes.includes('delegation')) {
const window = readLastEntries(stateFile, WINDOW_SIZE);
const escalation = checkEscalationAfterInput(window, entry);
if (escalation.detected && !hasEscalationWarning(window)) {
messages.push(formatEscalationWarning(detail, escalation.inputDetail));
appendEntry(stateFile, { type: 'escalation_warning', ts: Date.now() });
}
}
// --- CaMeL data flow check (v5.0 S6) ---
// Check if current tool input contains data that flowed from a previous tool output.
// If a data flow link is detected AND a trifecta is present, elevate severity.
if (!(classes.length === 1 && classes[0] === 'neutral')) {
const inputText = extractInputText(toolInput);
if (inputText.length >= 20) {
const window = readLastEntries(stateFile, WINDOW_SIZE);
const flowLink = checkDataFlowLink(window, inputText);
if (flowLink.linked && !hasDataFlowWarning(window)) {
// Check if a trifecta is also present
const { detected, evidence } = checkTrifecta(window);
if (detected) {
messages.push(formatDataFlowWarning(evidence, flowLink.sourceEntries));
appendEntry(stateFile, { type: 'data_flow_warning', ts: Date.now() });
}
}
}
}
// --- Cumulative volume tracking ---
if (outputSize > 0) {
const allEntries = readLastEntries(stateFile, 10_000); // read all
const totalVolume = computeCumulativeVolume(allEntries);
// Check thresholds from highest to lowest — only warn once per threshold
for (const { bytes, label, severity } of VOLUME_THRESHOLDS) {
if (totalVolume >= bytes && !hasVolumeWarning(allEntries, bytes)) {
messages.push(formatVolumeWarning(totalVolume, label, severity));
appendEntry(stateFile, { type: 'volume_warning', ts: Date.now(), threshold: bytes });
break; // only emit highest unwarned threshold
}
}
}
// --- Long-horizon monitoring (100-call window) ---
{
const longWindow = readLastEntries(stateFile, LONG_HORIZON_WINDOW);
// Slow-burn trifecta: all 3 legs spread over >50 calls
const slowBurn = checkSlowBurnTrifecta(longWindow);
if (slowBurn.detected && !hasSlowBurnWarning(longWindow)) {
messages.push(formatSlowBurnWarning(slowBurn.spread));
appendEntry(stateFile, { type: 'slow_burn_warning', ts: Date.now() });
}
// Behavioral drift: JSD on tool distribution (first vs last DRIFT_SAMPLE_SIZE)
const drift = checkBehavioralDrift(longWindow);
if (drift.drifted && !hasDriftWarning(longWindow)) {
messages.push(formatDriftWarning(drift.jsd, drift.firstTools, drift.lastTools));
appendEntry(stateFile, { type: 'drift_warning', ts: Date.now() });
}
}
// Emit combined advisory
if (messages.length > 0) {
const combined = messages.join('\n\n---\n\n');
process.stdout.write(JSON.stringify({ systemMessage: combined }));
}
// Default: advisory only (warn mode)
process.exit(0);

View file

@ -0,0 +1,206 @@
#!/usr/bin/env node
// Hook: pre-bash-destructive.mjs
// Event: PreToolUse (Bash)
// Purpose: Block or warn about destructive shell commands.
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input }
// - tool_input.command — the shell command string
// - BLOCK (exit 2): catastrophic/irreversible operations
// - WARN (exit 0): risky but recoverable operations — advisory message to stderr
// - Allow (exit 0): everything else
import { readFileSync } from 'node:fs';
import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs';
// ---------------------------------------------------------------------------
// BLOCK rules — exit 2, command is not executed.
// Each rule: { name, pattern, description }
// ---------------------------------------------------------------------------
const BLOCK_RULES = [
{
name: 'Filesystem root destruction (rm -rf /)',
pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:\/|~|\$HOME)\b/,
description:
'`rm -rf /`, `rm -rf ~`, and `rm -rf $HOME` would destroy the entire filesystem ' +
'or home directory. This command is unconditionally blocked.',
},
{
name: 'World-writable chmod (chmod 777)',
pattern: /\bchmod\s+(?:-[a-zA-Z]+\s+)*777\b/,
description:
'`chmod 777` grants full read/write/execute to all users, creating a severe ' +
'security vulnerability. Use the minimal permission set required (e.g. 644, 755).',
},
{
name: 'Pipe-to-shell (curl|sh, wget|sh, curl|bash)',
// Matches: curl ... | sh, curl ... | bash, wget ... | sh, etc.
// Also catches variations with xargs sh, xargs bash
pattern: /(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|ksh|dash)\b/,
description:
'Piping remote content directly into a shell interpreter allows ' +
'arbitrary remote code execution without inspection. Download the script first, ' +
'review it, then execute explicitly.',
},
{
name: 'Fork bomb',
pattern: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;?\s*:/,
description:
'This is a fork bomb that will exhaust system process resources and require a hard reboot. Blocked.',
},
{
name: 'Filesystem format (mkfs)',
pattern: /\bmkfs(?:\.[a-z0-9]+)?\s/,
description:
'`mkfs` formats a filesystem, destroying all data on the target device. ' +
'This is an irreversible operation and is blocked.',
},
{
name: 'Raw disk overwrite via dd',
// dd if=... of=/dev/sd* or of=/dev/nvme* or similar block devices
pattern: /\bdd\b[^&|;]*\bof=\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/,
description:
'`dd` writing to a raw block device (/dev/sd*, /dev/nvme*) will destroy partition ' +
'tables and data on that disk. Blocked to prevent accidental disk wipe.',
},
{
name: 'Direct device write (> /dev/sd* etc.)',
pattern: />\s*\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/,
description:
'Writing directly to a block device via shell redirection destroys disk data. Blocked.',
},
{
name: 'eval with variable/command expansion (potential injection)',
// eval $VAR, eval $(cmd), eval `cmd`, eval "$VAR"
pattern: /\beval\s+(?:`|\$[\({]|"[^"]*\$)/,
description:
'`eval` with variable or command substitution executes dynamically constructed ' +
'strings, which is a common code injection vector. Blocked. ' +
'Refactor to use explicit commands instead.',
},
];
// ---------------------------------------------------------------------------
// WARN rules — exit 0 with advisory message on stderr.
// Command is allowed to proceed but the user/agent is informed.
// ---------------------------------------------------------------------------
const WARN_RULES = [
{
name: 'Force push (git push --force)',
pattern: /\bgit\s+push\b[^|&;]*(?:--force|-f)\b/,
description:
'WARNING: `git push --force` rewrites remote history. This can destroy commits ' +
'for all collaborators on shared branches. Prefer `--force-with-lease`.',
},
{
name: 'Hard reset (git reset --hard)',
pattern: /\bgit\s+reset\s+--hard\b/,
description:
'WARNING: `git reset --hard` permanently discards uncommitted changes and ' +
'moves the branch pointer. Ensure you have no unsaved work.',
},
{
name: 'Recursive remove (rm -rf, non-root non-home target)',
// Warn for rm -rf that doesn't hit /, ~, or $HOME (those are BLOCKED above)
pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+/,
description:
'WARNING: `rm -rf` permanently deletes files and directories without recovery. ' +
'Verify the target path before proceeding.',
},
{
name: 'Docker system prune',
pattern: /\bdocker\s+system\s+prune\b/,
description:
'WARNING: `docker system prune` removes all stopped containers, unused images, ' +
'networks, and build cache. This may delete data needed for local development.',
},
{
name: 'npm publish',
pattern: /\bnpm\s+publish\b/,
description:
'WARNING: `npm publish` releases a package to the public npm registry. ' +
'Confirm the version, changelog, and that no secrets are bundled.',
},
{
name: 'DROP TABLE or DROP DATABASE (SQL)',
pattern: /\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i,
description:
'WARNING: SQL DROP statements permanently delete database objects and all their data. ' +
'Ensure you have a recent backup and are targeting the correct environment.',
},
{
name: 'DELETE without WHERE (SQL)',
pattern: /\bDELETE\s+FROM\s+\w+(?:\s*;|\s*$)/i,
description:
'WARNING: DELETE FROM without a WHERE clause deletes all rows in the table. ' +
'Ensure this is intentional and backed up.',
},
];
// ---------------------------------------------------------------------------
// Normalize command: strip ANSI, collapse whitespace, for pattern matching.
// We do NOT strip quotes entirely — patterns are designed to work with raw input.
// ---------------------------------------------------------------------------
function normalizeCommand(cmd) {
return cmd
// Remove ANSI escape codes
.replace(/\x1B\[[0-9;]*m/g, '')
// Collapse runs of whitespace (including newlines from heredocs) to single space
.replace(/\s+/g, ' ')
.trim();
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
// Cannot parse stdin — fail open.
process.exit(0);
}
const command = input?.tool_input?.command;
if (!command || typeof command !== 'string') {
process.exit(0);
}
// First strip bash evasion techniques (empty quotes, ${} expansion, backslash splitting),
// then apply standard normalization (ANSI strip, whitespace collapse).
const deobfuscated = normalizeBashExpansion(command);
const normalized = normalizeCommand(deobfuscated);
// Check BLOCK rules first
for (const rule of BLOCK_RULES) {
if (rule.pattern.test(normalized)) {
process.stderr.write(
`BLOCKED: Destructive command detected — ${rule.name}\n` +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
` ${rule.description}\n`
);
process.exit(2);
}
}
// Check WARN rules (advisory — still exit 0)
const warnings = [];
for (const rule of WARN_RULES) {
if (rule.pattern.test(normalized)) {
warnings.push(` [WARN] ${rule.name}: ${rule.description}`);
}
}
if (warnings.length > 0) {
process.stderr.write(
`SECURITY ADVISORY: Potentially risky command detected.\n` +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
warnings.join('\n') + '\n' +
` Proceeding — verify intent before confirming.\n`
);
}
// Allow (with or without warnings)
process.exit(0);

View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
// Hook: pre-edit-secrets.mjs (consolidated)
// Event: PreToolUse (Edit|Write)
// Purpose: Detect secrets/credentials in file content before writing.
// Consolidates patterns from global, kiur, llm-security, and ms-ai-architect.
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input }
// - tool_input.file_path — destination path
// - tool_input.content — full content (Write)
// - tool_input.new_string — replacement text (Edit)
// - Block: stderr + exit 2
// - Allow: exit 0
import { readFileSync } from 'node:fs';
import { normalize } from 'node:path';
// ---------------------------------------------------------------------------
// Secret detection patterns (union of global, kiur, llm-security, ms-ai-architect)
// ---------------------------------------------------------------------------
const SECRET_PATTERNS = [
{ name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ },
{ name: 'AWS Secret Access Key', pattern: /(?:aws_secret(?:_access)?_key|AWS_SECRET(?:_ACCESS)?_KEY)\s*[=:]\s*['"]?[0-9a-zA-Z/+=]{40}['"]?/i },
{ name: 'Azure Connection String (AccountKey/SharedAccessKey/sig)', pattern: /(?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,}/ },
{ name: 'Azure AD ClientSecret', pattern: /(?:client[_-]?secret|ClientSecret)\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Azure AI Services Key', pattern: /Ocp-Apim-Subscription-Key\s*[=:]\s*['"]?[0-9a-f]{32}['"]?/i },
{ name: 'GitHub Token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/ },
{ name: 'npm Token', pattern: /npm_[A-Za-z0-9]{36}/ },
{ name: 'Private Key PEM Block', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
{ name: 'JWT Secret', pattern: /JWT[_-]?SECRET\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Slack/Discord Webhook URL', pattern: /https:\/\/(?:hooks\.slack\.com\/services|discord(?:app)?\.com\/api\/webhooks)\// },
{ name: 'Generic credential assignment', pattern: /(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Authorization header with token', pattern: /[Bb]earer [A-Za-z0-9\-._~+/]{20,}/ },
{ name: 'Database connection string', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+/i },
];
// ---------------------------------------------------------------------------
// Exclusions: files that may contain example patterns for documentation
// ---------------------------------------------------------------------------
function isExcluded(filePath) {
if (!filePath) return false;
const n = normalize(filePath);
if (/[\\/]knowledge[\\/].+\.md$/i.test(n)) return true;
if (/[\\/]references[\\/].+\.md$/i.test(n)) return true;
if (/\.(test|spec|mock)\.[jt]sx?$/.test(n)) return true;
if (/\.(example|template|sample)(\.|$)/.test(n)) return true;
return false;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch { process.exit(0); }
const toolInput = input?.tool_input ?? {};
const filePath = toolInput.file_path ?? '';
if (isExcluded(filePath)) process.exit(0);
const contentToCheck = [toolInput.content ?? '', toolInput.new_string ?? ''].join('\n');
if (!contentToCheck.trim()) process.exit(0);
for (const { name, pattern } of SECRET_PATTERNS) {
if (pattern.test(contentToCheck)) {
process.stderr.write(
`BLOCKED: Potential secret detected — ${name}\n` +
` File: ${filePath || '(unknown)'}\n` +
` Remove the credential before writing. Use <YOUR_KEY_HERE> or .env.\n`
);
process.exit(2);
}
}
process.exit(0);

View file

@ -0,0 +1,710 @@
#!/usr/bin/env node
// Hook: pre-install-supply-chain.mjs
// Event: PreToolUse (Bash)
// Purpose: Analyze ALL package installs BEFORE execution.
//
// Covers: npm, yarn, pnpm, npx, pip, pip3, uv, brew, docker, go, cargo, gem
//
// Checks per manager:
// npm/yarn/pnpm: blocklist, npm audit, npm view (scripts + age gate)
// pip/pip3/uv: blocklist, PyPI API (age gate + metadata)
// brew: third-party tap warning, cask verification
// docker: unpinned tags, unverified images, known malicious
// go install: age gate via proxy.golang.org
// cargo: blocklist
// gem: blocklist
//
// Protocol:
// - BLOCK (exit 2): known compromised, critical CVEs, new + install scripts
// - WARN (exit 0): high CVEs, install scripts on established packages
// - Allow (exit 0): everything else
import { readFileSync, existsSync } from 'node:fs';
import {
AGE_THRESHOLD_HOURS,
NPM_COMPROMISED, PIP_COMPROMISED, CARGO_COMPROMISED, GEM_COMPROMISED,
DOCKER_SUSPICIOUS, POPULAR_PIP,
isCompromised, parseSpec, parsePipSpec, execSafe,
queryOSV, extractOSVSeverity,
} from '../../scanners/lib/supply-chain-data.mjs';
import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs';
// ===========================================================================
// Read stdin
// ===========================================================================
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
process.exit(0);
}
const command = input?.tool_input?.command;
if (!command || typeof command !== 'string') {
process.exit(0);
}
// First strip bash evasion techniques, then collapse whitespace
const normalized = normalizeBashExpansion(command).replace(/\s+/g, ' ').trim();
// ===========================================================================
// Quick gate — detect any package install command
// ===========================================================================
const GATES = {
npm: /\b(?:npm\s+(?:install|i|ci|add)|yarn\s+(?:add|install)|pnpm\s+(?:add|install|i))\b/,
npx: /\b(?:npx|pnpx)\s+\S/,
pip: /\b(?:pip3?\s+install|python3?\s+-m\s+pip\s+install|uv\s+pip\s+install|uv\s+add)\b/,
brew: /\b(?:brew\s+(?:install|tap))\b/,
docker: /\b(?:docker\s+(?:pull|run))\b/,
go: /\bgo\s+install\b/,
cargo: /\bcargo\s+install\b/,
gem: /\bgem\s+install\b/,
};
const detectedManager = Object.entries(GATES).find(([, re]) => re.test(normalized))?.[0];
if (!detectedManager) {
process.exit(0); // Not a package install command
}
// ===========================================================================
// Utility functions (only hook-specific ones remain; shared ones imported above)
// ===========================================================================
function extractArgs(cmd, installRegex) {
const match = cmd.match(installRegex);
if (!match) return [];
return match[1].split(/\s+/).filter(a => a && !a.startsWith('-') && !['true', 'false'].includes(a));
}
// ===========================================================================
// NPM checks
// ===========================================================================
async function checkNpm() {
const blocks = [];
const warnings = [];
const packages = extractNpmPackages(normalized);
const isBareInstall = packages.length === 0 && !GATES.npx.test(normalized);
if (isBareInstall) {
// Scan lockfile for known compromised
const lockFindings = scanNpmLockfile();
for (const f of lockFindings) {
blocks.push(
`COMPROMISED in lockfile (${f.source}): ${f.name}@${f.version}\n` +
` This package/version is on the known-compromised list.\n` +
` Remove it from your lockfile and package.json before installing.`
);
}
// npm audit
const audit = runNpmAudit();
if (audit.critical.length > 0) {
const list = audit.critical.map(v => ` - ${v.name} (${v.severity}): ${v.title}`).join('\n');
blocks.push(
`npm audit: ${audit.critical.length} CRITICAL vulnerabilities\n${list}\n` +
` Run \`npm audit fix\` or update affected packages before installing.`
);
}
if (audit.high.length > 0) {
const list = audit.high.map(v => ` - ${v.name} (${v.severity}): ${v.title}`).join('\n');
warnings.push(
`npm audit: ${audit.high.length} HIGH vulnerabilities\n${list}\n` +
` Consider running \`npm audit fix\` to resolve.`
);
}
}
for (const spec of packages) {
const { name, version } = parseSpec(spec);
if (isCompromised(NPM_COMPROMISED, name, version)) {
blocks.push(
`COMPROMISED: ${name}${version ? '@' + version : ''}\n` +
` Known supply chain attack. See: https://socket.dev/npm/package/${name}`
);
continue;
}
const meta = inspectNpmPackage(name, version);
if (!meta) continue;
const resolvedVersion = meta.version;
// --- Advisory check (OSV.dev) — catches compromised established packages ---
const advisories = await queryOSV('npm', name, resolvedVersion);
if (advisories.critical.length > 0) {
blocks.push(
`KNOWN VULNERABILITY: ${name}@${resolvedVersion}\n` +
advisories.critical.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n') + '\n' +
` This version has critical advisories. Use a patched version.`
);
continue;
}
if (advisories.high.length > 0) {
warnings.push(
`VULNERABILITY ADVISORY: ${name}@${resolvedVersion}\n` +
advisories.high.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n') + '\n' +
` Consider using a version without known vulnerabilities.`
);
}
// --- Git provenance check — catches hijacked publishes like axios ---
const provenance = checkNpmProvenance(meta);
if (provenance === 'suspicious') {
warnings.push(
`PROVENANCE WARNING: ${name}@${resolvedVersion}\n` +
` This version was published without matching git tag or CI attestation.\n` +
` It may have been published directly to npm (bypass CI) — as in the axios attack.\n` +
` Verify at: https://www.npmjs.com/package/${name}/v/${resolvedVersion}`
);
}
// --- Install scripts check ---
const scriptNames = ['preinstall', 'install', 'postinstall'].filter(s => meta.scripts?.[s]);
if (scriptNames.length === 0) continue;
const ageHours = getNpmPublishAge(meta);
const versionCount = meta.versions?.length || (meta.time ? Object.keys(meta.time).length - 2 : 0);
const isEstablished = versionCount >= 10;
if (ageHours !== null && ageHours < AGE_THRESHOLD_HOURS && !isEstablished) {
blocks.push(
`NEW PACKAGE WITH INSTALL SCRIPTS: ${name}@${resolvedVersion}\n` +
` Has: ${scriptNames.join(', ')}\n` +
` Published: ${Math.round(ageHours)}h ago, ${versionCount} version(s) total\n` +
` New packages with install scripts are the #1 supply chain attack vector.`
);
} else {
warnings.push(
`INSTALL SCRIPTS: ${name}@${resolvedVersion}\n` +
` Has: ${scriptNames.join(', ')}\n` +
` Note: ~/.npmrc has ignore-scripts=true, so these won't run.`
);
}
}
return { blocks, warnings };
}
function extractNpmPackages(cmd) {
const npxMatch = cmd.match(/\b(?:npx|pnpx)\s+(.+)/);
if (npxMatch) {
const args = npxMatch[1].split(/\s+/).filter(a => !a.startsWith('-'));
return args.length > 0 ? [args[0]] : [];
}
if (/\bnpm\s+ci\b/.test(cmd)) return [];
if (/\b(?:npm|yarn|pnpm)\s+(?:install|i)\s*$/.test(cmd.replace(/\s+--?\S+/g, '').trim())) return [];
const match = cmd.match(/\b(?:npm|yarn|pnpm)\s+(?:install|i|add)\s+(.*)/);
if (!match) return [];
return match[1].split(/\s+/).filter(a => a && !a.startsWith('-'));
}
// ---------------------------------------------------------------------------
// npm provenance check — detect publishes that bypassed CI
// If a package has .attestations but this version doesn't, or if the repo
// field exists but the version has no corresponding git tag, flag it.
// ---------------------------------------------------------------------------
function checkNpmProvenance(meta) {
if (!meta) return 'unknown';
// Check if package normally has attestations (npm provenance)
// Packages with sigstore attestations went through CI. Absence is suspicious.
const hasGitRepo = meta.repository?.url || meta.repository;
const hasAttestations = meta._attestations || meta.attestations;
// If the package declares a git repo but this specific version
// has no attestations AND was published very recently, flag it
if (hasGitRepo && !hasAttestations) {
const ageHours = getNpmPublishAge(meta);
// Only flag very recent publishes (< 24h) from packages that normally use CI
if (ageHours !== null && ageHours < 24) {
// Check if previous versions had attestations by checking dist.attestations
// This is a heuristic — not all packages use provenance yet
return 'suspicious';
}
}
return 'ok';
}
function inspectNpmPackage(name, version) {
const spec = version ? `${name}@${version}` : name;
const raw = execSafe(`npm view ${spec} --json`);
if (!raw) return null;
try { return JSON.parse(raw); } catch { return null; }
}
function getNpmPublishAge(meta) {
const timeField = meta?.time;
if (!timeField) return null;
const publishDate = typeof timeField === 'string' ? timeField : timeField[meta.version] || timeField.modified;
if (!publishDate) return null;
return (Date.now() - new Date(publishDate).getTime()) / (1000 * 60 * 60);
}
function scanNpmLockfile() {
const findings = [];
const cwd = process.env.CLAUDE_WORKING_DIR || process.cwd();
const lockPath = `${cwd}/package-lock.json`;
if (existsSync(lockPath)) {
try {
const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
for (const [key, info] of Object.entries(lock.packages || lock.dependencies || {})) {
const name = key.replace(/^node_modules\//, '');
if (name && isCompromised(NPM_COMPROMISED, name, info.version)) {
findings.push({ name, version: info.version, source: 'package-lock.json' });
}
}
} catch { /* ignore */ }
}
const yarnLock = `${cwd}/yarn.lock`;
if (existsSync(yarnLock)) {
try {
const content = readFileSync(yarnLock, 'utf-8');
for (const [pkg, versions] of Object.entries(NPM_COMPROMISED)) {
for (const v of versions) {
if (v === '*' ? content.includes(`${pkg}@`) : content.includes(`version "${v}"`) && content.includes(`${pkg}@`)) {
findings.push({ name: pkg, version: v === '*' ? '(any)' : v, source: 'yarn.lock' });
}
}
}
} catch { /* ignore */ }
}
return findings;
}
function runNpmAudit() {
const cwd = process.env.CLAUDE_WORKING_DIR || process.cwd();
if (!existsSync(`${cwd}/package-lock.json`)) return { critical: [], high: [] };
const raw = execSafe('npm audit --json', 15000);
if (!raw) return { critical: [], high: [] };
const critical = [];
const high = [];
try {
const audit = JSON.parse(raw);
for (const [name, info] of Object.entries(audit.vulnerabilities || {})) {
const title = Array.isArray(info.via) ? info.via.map(v => typeof v === 'string' ? v : v.title).join(', ') : String(info.via);
const entry = { name, severity: info.severity, title };
if (info.severity === 'critical') critical.push(entry);
else if (info.severity === 'high') high.push(entry);
}
} catch { /* ignore */ }
return { critical, high };
}
// ===========================================================================
// PIP checks
// ===========================================================================
async function checkPip() {
const blocks = [];
const warnings = [];
const packages = extractPipPackages(normalized);
// pip install (bare, from requirements.txt) — scan requirements for known bad
if (packages.length === 0) {
const reqFindings = scanRequirementsTxt();
for (const f of reqFindings) {
blocks.push(
`COMPROMISED in requirements: ${f.name}${f.version ? '==' + f.version : ''}\n` +
` This package is on the known-compromised list (typosquat/malware).`
);
}
return { blocks, warnings };
}
for (const spec of packages) {
const { name, version } = parsePipSpec(spec);
if (isCompromised(PIP_COMPROMISED, name, version)) {
blocks.push(
`COMPROMISED: ${name} (PyPI)\n` +
` Known malicious package (likely typosquat).\n` +
` See: https://pypi.org/project/${name}/`
);
continue;
}
// Check PyPI API for age and metadata
const meta = await inspectPyPIPackage(name, version);
if (!meta) continue;
const resolvedVersion = version || meta.info?.version;
// --- Advisory check (OSV.dev) — catches compromised established packages ---
const advisories = await queryOSV('pip', name, resolvedVersion);
if (advisories.critical.length > 0) {
blocks.push(
`KNOWN VULNERABILITY: ${name}==${resolvedVersion} (PyPI)\n` +
advisories.critical.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n') + '\n' +
` This version has critical advisories. Use a patched version.`
);
continue;
}
if (advisories.high.length > 0) {
warnings.push(
`VULNERABILITY ADVISORY: ${name}==${resolvedVersion} (PyPI)\n` +
advisories.high.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n') + '\n' +
` Consider using a version without known vulnerabilities.`
);
}
const ageHours = getPyPIPublishAge(meta, version);
const releaseCount = Object.keys(meta.releases || {}).length;
const isEstablished = releaseCount >= 10;
// Age gate only for genuinely new packages (few releases).
// Established packages (10+ releases) with a new version are normal — don't block.
if (ageHours !== null && ageHours < AGE_THRESHOLD_HOURS && !isEstablished) {
blocks.push(
`NEW PyPI PACKAGE: ${name}${version ? '==' + version : ''}\n` +
` Published: ${Math.round(ageHours)}h ago (threshold: ${AGE_THRESHOLD_HOURS}h)\n` +
` Only ${releaseCount} release(s) — this looks like a genuinely new package.\n` +
` New PyPI packages may contain malicious setup.py scripts.\n` +
` Wait ${AGE_THRESHOLD_HOURS}h or verify manually first.`
);
}
// Typosquat detection — Levenshtein distance to popular packages
const typosquatOf = checkTyposquat(name);
if (typosquatOf) {
warnings.push(
`POSSIBLE TYPOSQUAT: "${name}" is suspiciously similar to "${typosquatOf}"\n` +
` Verify this is the intended package before installing.`
);
}
}
return { blocks, warnings };
}
function extractPipPackages(cmd) {
// Handle: pip install pkg, pip3 install pkg, python -m pip install pkg, uv pip install pkg, uv add pkg
const match = cmd.match(/\b(?:pip3?\s+install|python3?\s+-m\s+pip\s+install|uv\s+pip\s+install|uv\s+add)\s+(.*)/);
if (!match) return [];
return match[1].split(/\s+/)
.filter(a => a && !a.startsWith('-') && !a.startsWith('/') && !a.endsWith('.txt') && !a.endsWith('.whl') && !a.endsWith('.tar.gz'));
}
async function inspectPyPIPackage(name, version) {
const url = version
? `https://pypi.org/pypi/${name}/${version}/json`
: `https://pypi.org/pypi/${name}/json`;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return null;
return await res.json();
} catch { return null; }
}
function getPyPIPublishAge(meta, requestedVersion) {
// PyPI returns upload_time per release
const version = requestedVersion || meta?.info?.version;
if (!version || !meta?.releases?.[version]) return null;
const files = meta.releases[version];
if (!files.length) return null;
const uploadTime = files[0].upload_time_iso_8601 || files[0].upload_time;
if (!uploadTime) return null;
return (Date.now() - new Date(uploadTime).getTime()) / (1000 * 60 * 60);
}
function scanRequirementsTxt() {
const findings = [];
const cwd = process.env.CLAUDE_WORKING_DIR || process.cwd();
for (const reqFile of ['requirements.txt', 'requirements-dev.txt', 'requirements.lock']) {
const path = `${cwd}/${reqFile}`;
if (!existsSync(path)) continue;
try {
const lines = readFileSync(path, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
const { name, version } = parsePipSpec(trimmed);
if (isCompromised(PIP_COMPROMISED, name, version)) {
findings.push({ name, version });
}
}
} catch { /* ignore */ }
}
return findings;
}
// levenshtein and checkTyposquat imported via POPULAR_PIP from supply-chain-data.mjs
// Local wrapper preserving hook's original behavior (normalizes differently than scanner)
function checkTyposquat(name) {
const lower = name.toLowerCase().replace(/[_.-]/g, '');
for (const popular of POPULAR_PIP) {
const popLower = popular.toLowerCase().replace(/[_.-]/g, '');
if (lower === popLower) continue;
const dist = levenshteinLocal(lower, popLower);
if (dist === 1 && lower.length > 3) return popular;
if (lower.length === popLower.length && dist <= 2 && lower.length > 5) {
const diffs = [...lower].filter((c, i) => c !== popLower[i]).length;
if (diffs <= 1) return popular;
}
}
return null;
}
// Hook-local levenshtein (O(m*n) matrix variant preserved for zero-dependency guarantee)
function levenshteinLocal(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
// ===========================================================================
// BREW checks
// ===========================================================================
function checkBrew() {
const blocks = [];
const warnings = [];
// brew tap — warn about third-party taps
if (/\bbrew\s+tap\s+/.test(normalized)) {
const tapMatch = normalized.match(/\bbrew\s+tap\s+(\S+)/);
if (tapMatch) {
const tap = tapMatch[1];
if (!tap.startsWith('homebrew/')) {
warnings.push(
`THIRD-PARTY TAP: ${tap}\n` +
` Only official Homebrew taps (homebrew/*) are curated.\n` +
` Third-party taps can contain arbitrary formulae. Verify the source.`
);
}
}
}
// brew install --cask — warn about cask source
if (/\bbrew\s+install\s+.*--cask/.test(normalized) || /\bbrew\s+install\s+--cask/.test(normalized)) {
warnings.push(
`CASK INSTALL: Casks install full macOS applications.\n` +
` Verify the publisher and download source before proceeding.`
);
}
return { blocks, warnings };
}
// ===========================================================================
// DOCKER checks
// ===========================================================================
function checkDocker() {
const blocks = [];
const warnings = [];
const imageMatch = normalized.match(/\bdocker\s+(?:pull|run)\s+(?:--[^\s]+\s+)*(\S+)/);
if (!imageMatch) return { blocks, warnings };
const image = imageMatch[1];
// Check for known malicious patterns
for (const pattern of DOCKER_SUSPICIOUS) {
if (pattern.test(image)) {
blocks.push(
`SUSPICIOUS DOCKER IMAGE: ${image}\n` +
` Matches known malicious pattern (cryptominer/malware).`
);
return { blocks, warnings };
}
}
// Unpinned tag (using :latest or no tag)
if (!image.includes(':') || image.endsWith(':latest')) {
warnings.push(
`UNPINNED DOCKER IMAGE: ${image}\n` +
` Using :latest or no tag means the image can change without notice.\n` +
` Pin to a specific digest: docker pull ${image.split(':')[0]}@sha256:<digest>`
);
}
// Unofficial image (no / means Docker Hub library, but user images have owner/)
if (image.includes('/') && !image.startsWith('library/')) {
const owner = image.split('/')[0];
// Not a known registry
if (!['docker.io', 'ghcr.io', 'gcr.io', 'mcr.microsoft.com', 'registry.k8s.io', 'quay.io', 'public.ecr.aws'].some(r => image.startsWith(r))) {
warnings.push(
`COMMUNITY DOCKER IMAGE: ${image}\n` +
` This is not an official Docker Hub image.\n` +
` Verify the publisher "${owner}" before running.`
);
}
}
return { blocks, warnings };
}
// ===========================================================================
// GO checks
// ===========================================================================
async function checkGo() {
const blocks = [];
const warnings = [];
const match = normalized.match(/\bgo\s+install\s+(\S+)/);
if (!match) return { blocks, warnings };
const pkg = match[1];
// Check module age via proxy.golang.org
const modPath = pkg.replace(/@.*$/, '');
const version = pkg.includes('@') ? pkg.split('@').pop() : null;
if (version && version !== 'latest') {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const res = await fetch(`https://proxy.golang.org/${modPath}/@v/${version}.info`, { signal: controller.signal });
clearTimeout(timer);
if (res.ok) {
const info = await res.json();
if (info.Time) {
const ageHours = (Date.now() - new Date(info.Time).getTime()) / (1000 * 60 * 60);
if (ageHours < AGE_THRESHOLD_HOURS) {
blocks.push(
`NEW GO MODULE: ${pkg}\n` +
` Published: ${Math.round(ageHours)}h ago (threshold: ${AGE_THRESHOLD_HOURS}h)\n` +
` go install compiles and runs code. Wait or verify manually.`
);
}
}
}
} catch { /* network error — fail open */ }
}
return { blocks, warnings };
}
// ===========================================================================
// CARGO checks
// ===========================================================================
async function checkCargo() {
const blocks = [];
const warnings = [];
const match = normalized.match(/\bcargo\s+install\s+(\S+)/);
if (!match) return { blocks, warnings };
const crate = match[1].replace(/^--.*/, '').trim();
if (!crate) return { blocks, warnings };
if (isCompromised(CARGO_COMPROMISED, crate, null)) {
blocks.push(
`COMPROMISED CRATE: ${crate}\n` +
` Known malicious Rust crate. See: https://crates.io/crates/${crate}`
);
} else {
// Check OSV for known vulns
const vMatch = normalized.match(/--version\s+(\S+)/);
const version = vMatch ? vMatch[1] : null;
if (version) {
const advisories = await queryOSV('cargo', crate, version);
if (advisories.critical.length > 0) {
blocks.push(
`KNOWN VULNERABILITY: ${crate}@${version} (crates.io)\n` +
advisories.critical.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n')
);
}
}
}
return { blocks, warnings };
}
// ===========================================================================
// GEM checks
// ===========================================================================
async function checkGem() {
const blocks = [];
const warnings = [];
const match = normalized.match(/\bgem\s+install\s+(\S+)/);
if (!match) return { blocks, warnings };
const spec = match[1];
const dashV = normalized.match(/-v\s+['"]?([0-9][0-9a-zA-Z._-]*)['"]?/);
const version = dashV ? dashV[1] : null;
if (isCompromised(GEM_COMPROMISED, spec, version)) {
blocks.push(
`COMPROMISED GEM: ${spec}${version ? '@' + version : ''}\n` +
` Known backdoored version. See: https://rubygems.org/gems/${spec}`
);
} else if (version) {
const advisories = await queryOSV('gem', spec, version);
if (advisories.critical.length > 0) {
blocks.push(
`KNOWN VULNERABILITY: ${spec}@${version} (RubyGems)\n` +
advisories.critical.map(a => ` - [${a.severity}] ${a.id}: ${a.summary}`).join('\n')
);
}
}
return { blocks, warnings };
}
// ===========================================================================
// Main — dispatch to correct checker
// ===========================================================================
const checkers = {
npm: checkNpm,
npx: checkNpm, // npx uses the same npm ecosystem
pip: checkPip,
brew: checkBrew,
docker: checkDocker,
go: checkGo,
cargo: checkCargo,
gem: checkGem,
};
const checker = checkers[detectedManager];
if (!checker) process.exit(0);
const { blocks, warnings } = await checker();
if (blocks.length > 0) {
process.stderr.write(
`\n🛑 BLOCKED: Supply chain risk detected [${detectedManager}]\n` +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n\n` +
blocks.map(b => ` ${b}`).join('\n\n') + '\n\n' +
` The command was NOT executed.\n`
);
process.exit(2);
}
if (warnings.length > 0) {
process.stderr.write(
`\n⚠️ Supply chain advisory [${detectedManager}]:\n` +
warnings.map(w => ` ${w}`).join('\n\n') + '\n\n'
);
}
process.exit(0);

View file

@ -0,0 +1,134 @@
#!/usr/bin/env node
// Hook: pre-prompt-inject-scan.mjs
// Event: UserPromptSubmit
// Purpose: Scan user prompts for injection patterns before sending to model.
//
// Catches injection hidden in pasted content, piped input, or headless mode.
// Critical patterns (direct override, spoofed headers, identity redefinition) -> block.
// High patterns (subtle manipulation, context normalization) -> warn.
// Medium patterns (leetspeak, homoglyphs, zero-width, multi-language) -> advisory.
//
// v2.3.0: LLM_SECURITY_INJECTION_MODE env var (block/warn/off). Default: block.
// v5.0.0: MEDIUM patterns emit advisory (never block). Appended to existing advisory
// when critical/high patterns are also present.
//
// Protocol:
// - Read JSON from stdin: { session_id, message: { role, content } }
// - content may be a string or array of content blocks
// - Block: exit 2, stdout JSON { decision: "block", reason: "..." }
// - Allow: exit 0
// - Warn: exit 0, stdout JSON { systemMessage: "..." }
import { readFileSync } from 'node:fs';
import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs';
// ---------------------------------------------------------------------------
// Mode configuration
// ---------------------------------------------------------------------------
const VALID_MODES = new Set(['block', 'warn', 'off']);
const mode = VALID_MODES.has(process.env.LLM_SECURITY_INJECTION_MODE)
? process.env.LLM_SECURITY_INJECTION_MODE
: 'block';
// Off mode: skip scanning entirely
if (mode === 'off') {
process.exit(0);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Extract plaintext from the UserPromptSubmit input payload.
* Handles multiple input shapes for robustness.
*/
function extractText(input) {
// Shape 1: { message: { content: "string" } }
// Shape 2: { message: { content: [{ type: "text", text: "..." }] } }
// Shape 3: { prompt: "string" } (fallback)
const message = input?.message;
if (!message) return input?.prompt ?? '';
const content = message.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('\n');
}
return '';
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
// Cannot parse stdin — allow (don't block on parse errors)
process.exit(0);
}
const text = extractText(input);
if (!text.trim()) {
process.exit(0);
}
const { critical, high, medium } = scanForInjection(text);
if (critical.length > 0 && mode === 'block') {
const reason =
`Blocked: prompt injection pattern detected (OWASP LLM01).\n` +
critical.map((c) => ` - ${c}`).join('\n') +
'\n' +
` This prompt contains patterns associated with prompt injection attacks.\n` +
` If intentional (testing, security research), set LLM_SECURITY_INJECTION_MODE=warn to allow with advisory.`;
process.stdout.write(JSON.stringify({ decision: 'block', reason }));
process.exit(2);
}
if (critical.length > 0 || high.length > 0) {
// In warn mode, critical patterns are downgraded to advisory.
// In block mode, we only reach here if critical is empty (only high patterns).
const allFindings = [...critical, ...high];
const severity = critical.length > 0 ? 'CRITICAL' : 'HIGH';
let message =
`SECURITY ADVISORY (prompt-inject-scan): ${severity} manipulation signals detected.\n\n` +
allFindings.map((f, i) => `[${i + 1}] ${f}`).join('\n') +
'\n\n' +
` These patterns may indicate prompt manipulation in pasted content.\n` +
` Review the source before proceeding.` +
(mode === 'warn' && critical.length > 0
? `\n Note: blocking is disabled (LLM_SECURITY_INJECTION_MODE=warn).`
: '');
// Append MEDIUM count if present (never list individual medium findings with critical/high)
if (medium.length > 0) {
message += `\n Additionally, ${medium.length} lower-confidence signal(s) detected (MEDIUM).`;
}
process.stdout.write(JSON.stringify({ decision: 'allow', systemMessage: message }));
process.exit(0);
}
// MEDIUM-only: advisory (never block)
if (medium.length > 0) {
const message =
`SECURITY ADVISORY (prompt-inject-scan): MEDIUM obfuscation/manipulation signals detected.\n\n` +
medium.map((f, i) => `[${i + 1}] ${f}`).join('\n') +
'\n\n' +
` These patterns may indicate obfuscated prompt manipulation (leetspeak, homoglyphs, multi-language).\n` +
` Review the source before proceeding. MEDIUM signals are advisory-only and never block.`;
process.stdout.write(JSON.stringify({ decision: 'allow', systemMessage: message }));
process.exit(0);
}
// Clean — allow silently
process.exit(0);

View file

@ -0,0 +1,181 @@
#!/usr/bin/env node
// Hook: pre-write-pathguard.mjs
// Event: PreToolUse (Write)
// Purpose: Block writes to sensitive paths (.env, .ssh/, .aws/, credentials, etc.)
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input }
// - tool_input.file_path — destination path
// - Block: stderr + exit 2
// - Allow: exit 0
import { readFileSync } from 'node:fs';
import { basename, normalize, resolve } from 'node:path';
// ---------------------------------------------------------------------------
// Sensitive path patterns — 8 categories
// ---------------------------------------------------------------------------
/** Category 1: Environment files */
const ENV_PATTERNS = [
/[\\/]\.env$/,
/[\\/]\.env\.[a-z]+$/, // .env.local, .env.production, etc.
/[\\/]\.env\.local$/,
];
/** Category 2: SSH directory */
const SSH_PATTERNS = [
/[\\/]\.ssh[\\/]/,
];
/** Category 3: AWS credentials */
const AWS_PATTERNS = [
/[\\/]\.aws[\\/]/,
];
/** Category 4: GPG directory */
const GPG_PATTERNS = [
/[\\/]\.gnupg[\\/]/,
];
/** Category 5: Credential files */
const CREDENTIAL_FILES = [
'.npmrc',
'.pypirc',
'.netrc',
'.docker/config.json',
'credentials.json',
'service-account.json',
'keyfile.json',
];
/** Category 6: Hook scripts (prevent hook tampering) */
const HOOK_PATTERNS = [
/[\\/]\.claude[\\/].*hooks.*\.json$/,
/[\\/]hooks[\\/]scripts[\\/].*\.mjs$/,
];
/** Category 7: System directories */
const SYSTEM_PATTERNS = [
/^\/etc[\\/]/,
/^\/usr[\\/]/,
/^\/var[\\/]/,
];
/** Category 8: Settings files */
const SETTINGS_FILES = [
'settings.json',
'settings.local.json',
];
// ---------------------------------------------------------------------------
// Path classification
// ---------------------------------------------------------------------------
/**
* Check if a file path targets a sensitive location.
* @param {string} filePath - The path to check
* @returns {{ blocked: boolean, category: string, reason: string }}
*/
function classifyPath(filePath) {
if (!filePath) return { blocked: false, category: '', reason: '' };
const norm = normalize(resolve(filePath));
const base = basename(norm);
// Category 1: Environment files
for (const pat of ENV_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'env', reason: `Environment file: ${base}` };
}
}
// Category 2: SSH
for (const pat of SSH_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'ssh', reason: `SSH directory: ${norm}` };
}
}
// Category 3: AWS
for (const pat of AWS_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'aws', reason: `AWS credentials directory: ${norm}` };
}
}
// Category 4: GPG
for (const pat of GPG_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'gnupg', reason: `GPG directory: ${norm}` };
}
}
// Category 5: Credential files
for (const name of CREDENTIAL_FILES) {
if (norm.endsWith(name) || base === name) {
return { blocked: true, category: 'credentials', reason: `Credential file: ${base}` };
}
}
// Category 6: Hook scripts
for (const pat of HOOK_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'hooks', reason: `Hook configuration: ${base}` };
}
}
// Category 7: System directories
for (const pat of SYSTEM_PATTERNS) {
if (pat.test(norm)) {
return { blocked: true, category: 'system', reason: `System directory: ${norm}` };
}
}
// Category 8: Settings files
for (const name of SETTINGS_FILES) {
if (base === name) {
// Only block settings.json in .claude/ directories
if (/[\\/]\.claude[\\/]/.test(norm) || /[\\/]\.vscode[\\/]/.test(norm)) {
return { blocked: true, category: 'settings', reason: `Settings file: ${norm}` };
}
}
}
return { blocked: false, category: '', reason: '' };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let input;
try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
process.exit(0);
}
const toolInput = input?.tool_input ?? {};
const filePath = toolInput.file_path ?? '';
if (!filePath) {
process.exit(0);
}
const result = classifyPath(filePath);
if (result.blocked) {
process.stderr.write(
`\n[llm-security] PATH GUARD: Write blocked\n` +
` Category: ${result.category}\n` +
` Reason: ${result.reason}\n` +
` Path: ${filePath}\n\n` +
`This path is protected. If this write is intentional, ` +
`ask the user to perform it manually.\n`
);
process.exit(2);
}
process.exit(0);

View file

@ -0,0 +1,140 @@
#!/usr/bin/env node
// Hook: update-check.mjs
// Event: UserPromptSubmit
// Purpose: Check for newer plugin versions (max 1x/24h, cached).
//
// Protocol:
// - Read JSON from stdin (consume, don't use)
// - If newer version available: exit 0, stdout JSON { systemMessage: "..." }
// - Otherwise: exit 0 silently
// - Never block the user (always exit 0)
//
// Disable: LLM_SECURITY_UPDATE_CHECK=off
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
// ---------------------------------------------------------------------------
// Exports for testing
// ---------------------------------------------------------------------------
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Return true if `remote` is a newer semver than `local`.
* Simple numeric comparison no pre-release/build metadata.
*/
export function isNewer(remote, local) {
const r = remote.split('.').map(Number);
const l = local.split('.').map(Number);
for (let i = 0; i < Math.max(r.length, l.length); i++) {
const rv = r[i] ?? 0;
const lv = l[i] ?? 0;
if (rv > lv) return true;
if (rv < lv) return false;
}
return false;
}
// ---------------------------------------------------------------------------
// Main (only runs when executed directly, not when imported for tests)
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const isDirectExecution = process.argv[1] &&
resolve(process.argv[1]) === resolve(__dirname, 'update-check.mjs');
if (isDirectExecution) {
main().catch(() => process.exit(0));
}
async function main() {
// Opt-out
if (process.env.LLM_SECURITY_UPDATE_CHECK === 'off') {
process.exit(0);
}
// Consume stdin (prevent pipe errors)
try { readFileSync(0, 'utf8'); } catch { /* ignore */ }
// Resolve plugin root
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || resolve(__dirname, '../..');
// Read installed version
let installed;
try {
const pluginJson = JSON.parse(readFileSync(resolve(pluginRoot, '.claude-plugin/plugin.json'), 'utf8'));
installed = pluginJson.version;
} catch {
process.exit(0);
}
// Read repo URL
let repoUrl;
try {
const pkg = JSON.parse(readFileSync(resolve(pluginRoot, 'package.json'), 'utf8'));
repoUrl = pkg.repository?.url;
} catch {
process.exit(0);
}
if (!installed || !repoUrl) process.exit(0);
// Cache
const cacheDir = resolve(homedir(), '.cache/llm-security');
const cachePath = resolve(cacheDir, 'update-check.json');
// Check cache
try {
if (existsSync(cachePath)) {
const cache = JSON.parse(readFileSync(cachePath, 'utf8'));
if (Date.now() - cache.checkedAt < CHECK_INTERVAL_MS) {
// Cache is fresh
if (cache.latestVersion && isNewer(cache.latestVersion, installed)) {
console.log(JSON.stringify({
systemMessage: `🔄 llm-security v${installed} → v${cache.latestVersion} available. Update: ${repoUrl}`
}));
}
process.exit(0);
}
}
} catch {
// Corrupt cache — proceed to fetch
}
// Fetch latest version from Forgejo raw API
const fetchUrl = `${repoUrl}/raw/branch/main/.claude-plugin/plugin.json`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(fetchUrl, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) process.exit(0);
const remote = JSON.parse(await res.text());
const latestVersion = remote.version;
if (!latestVersion) process.exit(0);
// Write cache
try {
mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, JSON.stringify({ checkedAt: Date.now(), latestVersion }));
} catch {
// Cache write failure is non-fatal
}
// Notify if newer
if (isNewer(latestVersion, installed)) {
console.log(JSON.stringify({
systemMessage: `🔄 llm-security v${installed} → v${latestVersion} available. Update: ${repoUrl}`
}));
}
} catch {
// Network error, timeout, parse error — silent exit
}
process.exit(0);
}