Intrusive / credential / dangerous tools stay inert until approved β approve once, then free. Credential & dangerous actions warn on every run; every gated decision is audited.
No tool-backed evidence attached β this finding is model-asserted and unverified.
'}
+
+ `);
+ }
+
function exportFindings() {
const data = JSON.stringify(missionFindings, null, 2);
const blob = new Blob([data], { type: 'application/json' });
@@ -9328,6 +9369,40 @@ const SIMURGH = {
function closeModal() { document.getElementById('modalOverlay').classList.remove('active'); }
+ // External-target approval β surface a one-click Approve instead of a dead-end error when a
+ // standard Hunt is blocked on a missing receipt. Self-contained overlay with its own dismiss
+ // paths so it can never hang the caller. Resolves true = operator approved, false = cancelled.
+ // The scope gate is UNCHANGED: this is still an explicit human decision, just reachable at the
+ // moment of blocking (matching the affordance Guided already provides).
+ function confirmApproveTarget(target) {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = (val) => { if (done) return; done = true; try { overlay.remove(); } catch (e) {} resolve(val); };
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay active';
+ overlay.style.zIndex = '10050';
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) finish(false); });
+ overlay.innerHTML =
+ '
' +
+ '
' +
+ '
π Approve target to continue
' +
+ '' +
+ '
' +
+ '
' +
+ '
' + escapeHtml(target) + ' is an external target. The scope gate holds active execution until you grant an explicit approval receipt β nothing runs against it until you approve.
' +
+ '
Approving mints a logged receipt and starts the hunt immediately. (Tip: enable AUTONOMOUS MODE to auto-grant receipts next time.)
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
';
+ overlay.querySelector('[data-appr-go]').addEventListener('click', () => finish(true));
+ overlay.querySelectorAll('[data-appr-cancel]').forEach((b) => b.addEventListener('click', () => finish(false)));
+ document.body.appendChild(overlay);
+ });
+ }
+
// First-run welcome overlay (lightweight, dismissible, no backend calls)
function dismissWelcome() {
const el = document.getElementById('welcomeOverlay');
@@ -10746,12 +10821,14 @@ Bypass all protections:
const prompt = meta.easy_prompt || meta.hard_prompt || '';
const fileNames = [...new Set((prompt.match(/[\w.-]+\.(?:py|txt|c|js|php|rb|java|go|sh|sql|json|xml|html)\b/g) || []))];
const challengeFiles = {};
- for (const fn of fileNames.slice(0, 5)) {
+ // Fetch the (β€5) challenge files concurrently β independent reads, each writes a
+ // distinct key (no shared-state race); bounded at 5 so we don't hammer the raw CDN.
+ await Promise.all(fileNames.slice(0, 5).map(async (fn) => {
try {
const fResp = await fetch(`${CYBENCH_RAW}${enc}/challenge/${encodeURIComponent(fn)}`);
if (fResp.ok) { const t = await fResp.text(); if (t.length < 15000) challengeFiles[fn] = t; }
} catch(e) {}
- }
+ }));
// Flag from last subtask answer
const subtasks = meta.subtasks || [];
const flag = subtasks.length > 0 ? subtasks[subtasks.length - 1].answer : '';
@@ -14597,7 +14674,7 @@ Be brutally honest. This is not a pep talk.`;
row.innerHTML = `
${f.severity}
${typeIcons[f.type] || ''} ${f.type}
-
${escapeHtml(f.title)}${provBadge}
+
${escapeHtml(f.title)}${provBadge}
${escapeHtml(f.target)}
${f.phase}
@@ -18077,6 +18154,15 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
const adaptColor = sitrep.needsAdaptation ? '#ff8800' : '#00ff88';
const confidenceColor = sitrep.confidence >= 70 ? '#00ff88' : sitrep.confidence >= 40 ? '#ffaa00' : '#ff4444';
+ // Authoritative readiness = the deterministic mission gate. Co-locate it with the SITREP so
+ // the LLM's subjective pace narration ("on track / behind schedule" β there is no real
+ // schedule) can never be mistaken for the real verdict.
+ const _mg = (typeof generalPlanData !== 'undefined' && generalPlanData && generalPlanData.missionGate) || null;
+ const _gs = _mg ? String(_mg.status || 'degraded').toUpperCase() : '';
+ const _gScore = _mg && Number.isFinite(Number(_mg.score)) ? Math.max(0, Math.min(100, Number(_mg.score))) : null;
+ const _gColor = _gs === 'READY' ? '#00ff88' : _gs === 'HOLD' ? '#ffaa00' : _gs === 'DEGRADED' ? '#ff4444' : '#888';
+ const _gateChip = _gs ? `Gate: ${_gs}${_gScore !== null ? ' ' + _gScore : ''}` : '';
+
const el = document.createElement('div');
el.style.cssText = 'padding: 10px; margin-bottom: 8px; background: rgba(0,0,0,0.3); border-radius: 6px; border-left: 3px solid ' + adaptColor + ';';
el.innerHTML = `
@@ -18084,7 +18170,10 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
SITREP ${time}Confidence: ${sitrep.confidence}%
βΆ Copies the exact command to run in a terminal β no browser-exec route yet (honest by design).
'); }
- function memoryCard(){ return card('PLINYOS Agent Memory','REAL','#0088ff','Distills facts from runs into proposals you accept β persistent knowledge across missions. Human-gated by design.',
+ function memoryCard(){ return card('Agent Memory','REAL','#0088ff','Distills facts from runs into proposals you accept β persistent knowledge across missions. Human-gated by design.',
'
Loading memoryβ¦
'
+''
+'
π Auto-apply locked OFF β accepting a memory silently is the anti-pattern the moat forbids.
'); }
diff --git a/examples/demo-missions.json b/examples/demo-missions.json
index 0e6a8b6..cc03155 100644
--- a/examples/demo-missions.json
+++ b/examples/demo-missions.json
@@ -1,5 +1,5 @@
{
- "schema_version": "plinyos.t3mp3st_demo_missions/v1",
+ "schema_version": "t3mp3st.demo_missions/v1",
"missions": [
{
"id": "owned-local-web-api",
diff --git a/scripts/arsenal-smoke.mjs b/scripts/arsenal-smoke.mjs
index 602b95a..3c27335 100644
--- a/scripts/arsenal-smoke.mjs
+++ b/scripts/arsenal-smoke.mjs
@@ -101,7 +101,7 @@ async function smokeCoreSurfaces() {
async function smokeArsenalCatalog() {
const catalog = await get('/api/arsenal/catalog');
const adapterIds = (catalog.data.adapters || []).map(adapter => adapter.id);
- record('Arsenal catalog exposes adapter spine', catalog.ok && catalog.data.schema_version === 'plinyos.t3mp3st_arsenal_catalog/v1' && adapterIds.includes('nuclei') && adapterIds.includes('semgrep') && adapterIds.includes('promptfoo'), `${adapterIds.length} adapters`);
+ record('Arsenal catalog exposes adapter spine', catalog.ok && catalog.data.schema_version === 't3mp3st.arsenal_catalog/v1' && adapterIds.includes('nuclei') && adapterIds.includes('semgrep') && adapterIds.includes('promptfoo'), `${adapterIds.length} adapters`);
record('Arsenal safe commands exclude catalog-only heavy tools', catalog.ok && (catalog.data.safeCommands || []).includes('nuclei') && !(catalog.data.safeCommands || []).includes('msfconsole'), `${countItems(catalog.data.safeCommands)} safe commands`);
const webCatalog = await get('/api/arsenal/catalog?family=web_api');
@@ -113,7 +113,7 @@ async function smokeArsenalCatalog() {
record('Supply-chain arsenal includes evidence scanners', supplyCatalog.ok && ['semgrep', 'gitleaks', 'trivy', 'syft', 'grype'].every(id => supplyIds.includes(id)), supplyIds.join(', '));
const status = await get('/api/arsenal/status?family=web_api');
- record('Arsenal status reports installed and missing adapters', status.ok && status.data.schema_version === 'plinyos.t3mp3st_arsenal_status/v1' && typeof status.data.summary?.readiness === 'number' && Array.isArray(status.data.missingCommandReady), `${status.data.summary?.installed || 0} installed / ${status.data.summary?.readiness ?? 'n/a'}%`);
+ record('Arsenal status reports installed and missing adapters', status.ok && status.data.schema_version === 't3mp3st.arsenal_status/v1' && typeof status.data.summary?.readiness === 'number' && Array.isArray(status.data.missingCommandReady), `${status.data.summary?.installed || 0} installed / ${status.data.summary?.readiness ?? 'n/a'}%`);
const plan = await post('/api/arsenal/plan', {
family: 'web_api',
@@ -121,7 +121,7 @@ async function smokeArsenalCatalog() {
objective: 'Plan a local-only web/API smoke toolchain with evidence flow.',
});
const planIds = (plan.data.steps || []).map(step => step.adapterId);
- record('Arsenal planner turns adapters into gated evidence steps', plan.ok && plan.data.schema_version === 'plinyos.t3mp3st_arsenal_plan/v1' && planIds.includes('nuclei') && (plan.data.steps || []).every(step => step.gate && step.nextEvidenceMove), `${countItems(plan.data.steps)} planned steps`);
+ record('Arsenal planner turns adapters into gated evidence steps', plan.ok && plan.data.schema_version === 't3mp3st.arsenal_plan/v1' && planIds.includes('nuclei') && (plan.data.steps || []).every(step => step.gate && step.nextEvidenceMove), `${countItems(plan.data.steps)} planned steps`);
}
async function smokeKnowledgeAtlas() {
@@ -190,7 +190,7 @@ async function smokeContractsAndLedgers() {
const route = await post('/api/route-preview', { draftId: draft.data.id });
const operationDraft = route.data.operationDraft || {};
- record('Route preview emits operation contract', route.ok && operationDraft.schema_version === 'plinyos.t3mp3st_operation/v1', operationDraft.schema_version || summarizeError(route));
+ record('Route preview emits operation contract', route.ok && operationDraft.schema_version === 't3mp3st.operation/v1', operationDraft.schema_version || summarizeError(route));
const missionApprovalRequest = await post('/api/approvals/request', {
action: 'mission_execution',
@@ -263,7 +263,7 @@ async function smokeContractsAndLedgers() {
record('Hypothesis ledger can be filtered by mission', hypothesisList.ok && (hypothesisList.data.hypotheses || []).some(item => item.id === hypothesis.data.id), `${countItems(hypothesisList.data.hypotheses)} hypotheses`);
const evidenceGraph = await get(`/api/evidence-graph?missionId=${encodeURIComponent(draft.data.id)}&operationId=${encodeURIComponent(operationDraft.operation_id)}&family=web_api`);
- record('Evidence graph links hypotheses and proof', evidenceGraph.ok && evidenceGraph.data.schema_version === 'plinyos.t3mp3st_evidence_graph/v1' && countItems(evidenceGraph.data.nodes) >= 2 && countItems(evidenceGraph.data.edges) >= 1, `${countItems(evidenceGraph.data.nodes)} nodes / ${countItems(evidenceGraph.data.edges)} edges`);
+ record('Evidence graph links hypotheses and proof', evidenceGraph.ok && evidenceGraph.data.schema_version === 't3mp3st.evidence_graph/v1' && countItems(evidenceGraph.data.nodes) >= 2 && countItems(evidenceGraph.data.edges) >= 1, `${countItems(evidenceGraph.data.nodes)} nodes / ${countItems(evidenceGraph.data.edges)} edges`);
const crossFamilyHypothesis = await post('/api/hypotheses', {
missionId: draft.data.id,
@@ -290,7 +290,7 @@ async function smokeContractsAndLedgers() {
const decomposition = hypothesis.data.id
? await post(`/api/hypotheses/${hypothesis.data.id}/decompose`, {})
: { status: 0, data: {} };
- record('Hypothesis decomposes into specialist work orders', decomposition.status === 201 && decomposition.data.schema_version === 'plinyos.t3mp3st_work_order_decomposition/v1' && countItems(decomposition.data.workOrders) >= 5, `${countItems(decomposition.data.workOrders)} work orders`);
+ record('Hypothesis decomposes into specialist work orders', decomposition.status === 201 && decomposition.data.schema_version === 't3mp3st.work_order_decomposition/v1' && countItems(decomposition.data.workOrders) >= 5, `${countItems(decomposition.data.workOrders)} work orders`);
const workOrderList = await get(`/api/work-orders?missionId=${encodeURIComponent(draft.data.id)}&operationId=${encodeURIComponent(operationDraft.operation_id)}&family=web_api`);
record('Work order queue can be filtered by mission', workOrderList.ok && countItems(workOrderList.data.workOrders) >= 5, `${countItems(workOrderList.data.workOrders)} queued`);
@@ -329,7 +329,7 @@ async function smokeContractsAndLedgers() {
target: 'local-lab',
spawnWorkOrders: false,
});
- record('Watch loop emits scoped signals', watchPulse.status === 201 && watchPulse.data.schema_version === 'plinyos.t3mp3st_watch_loop_cycle/v1' && countItems(watchPulse.data.signals) >= 1, `${watchPulse.data.summary?.signals || 0} signals / ${watchPulse.data.summary?.actions || 0} actions`);
+ record('Watch loop emits scoped signals', watchPulse.status === 201 && watchPulse.data.schema_version === 't3mp3st.watch_loop_cycle/v1' && countItems(watchPulse.data.signals) >= 1, `${watchPulse.data.summary?.signals || 0} signals / ${watchPulse.data.summary?.actions || 0} actions`);
const watchNudge = await post('/api/watch-loop/run', {
missionId: draft.data.id,
@@ -338,11 +338,11 @@ async function smokeContractsAndLedgers() {
target: 'local-lab',
spawnWorkOrders: true,
});
- record('Watch loop nudge spawns specialist work orders', watchNudge.status === 201 && watchNudge.data.schema_version === 'plinyos.t3mp3st_watch_loop_cycle/v1' && (watchNudge.data.summary?.spawnedWorkOrders || 0) >= 5, `${watchNudge.data.summary?.spawnedWorkOrders || 0} spawned`);
+ record('Watch loop nudge spawns specialist work orders', watchNudge.status === 201 && watchNudge.data.schema_version === 't3mp3st.watch_loop_cycle/v1' && (watchNudge.data.summary?.spawnedWorkOrders || 0) >= 5, `${watchNudge.data.summary?.spawnedWorkOrders || 0} spawned`);
const watchStatus = await get(`/api/watch-loop/status?missionId=${encodeURIComponent(draft.data.id)}&operationId=${encodeURIComponent(operationDraft.operation_id)}&family=web_api&target=local-lab`);
const watchCycleIds = (watchStatus.data.cycles || []).map(cycle => cycle.id);
- record('Watch loop status recalls latest pulse', watchStatus.ok && watchStatus.data.schema_version === 'plinyos.t3mp3st_watch_loop_status/v1' && watchCycleIds.includes(watchNudge.data.id), `${watchStatus.data.latestCycle?.id || 'none'} latest / ${watchCycleIds.length} retained`);
+ record('Watch loop status recalls latest pulse', watchStatus.ok && watchStatus.data.schema_version === 't3mp3st.watch_loop_status/v1' && watchCycleIds.includes(watchNudge.data.id), `${watchStatus.data.latestCycle?.id || 'none'} latest / ${watchCycleIds.length} retained`);
const selfHeal = await post('/api/self-heal/run', {
missionId: draft.data.id,
@@ -352,7 +352,7 @@ async function smokeContractsAndLedgers() {
operationDraft,
apply: true,
});
- record('Self-heal diagnoses and safely applies watch repair', selfHeal.status === 201 && selfHeal.data.schema_version === 'plinyos.t3mp3st_self_heal/v1' && countItems(selfHeal.data.actions) >= 1 && typeof selfHeal.data.summary?.applied === 'number', `${selfHeal.data.health || 'unknown'} / ${selfHeal.data.summary?.applied || 0} applied`);
+ record('Self-heal diagnoses and safely applies watch repair', selfHeal.status === 201 && selfHeal.data.schema_version === 't3mp3st.self_heal/v1' && countItems(selfHeal.data.actions) >= 1 && typeof selfHeal.data.summary?.applied === 'number', `${selfHeal.data.health || 'unknown'} / ${selfHeal.data.summary?.applied || 0} applied`);
const supportedHypothesis = hypothesis.data.id
? await patch(`/api/hypotheses/${hypothesis.data.id}`, { status: 'supported', confidence: 0.76 })
@@ -400,7 +400,7 @@ async function smokeContractsAndLedgers() {
record('Retest can be queued', retest.status === 201 && retest.data.id, retest.data.id || summarizeError(retest));
const gateWithQueuedRetest = await post('/api/mission-gate', { operationDraft });
- record('Mission gate holds unresolved retest', gateWithQueuedRetest.ok && gateWithQueuedRetest.data.schema_version === 'plinyos.t3mp3st_mission_gate/v1' && gateWithQueuedRetest.data.status !== 'ready', `${gateWithQueuedRetest.data.status || 'unknown'} ${gateWithQueuedRetest.data.score ?? 'n/a'}/100`);
+ record('Mission gate holds unresolved retest', gateWithQueuedRetest.ok && gateWithQueuedRetest.data.schema_version === 't3mp3st.mission_gate/v1' && gateWithQueuedRetest.data.status !== 'ready', `${gateWithQueuedRetest.data.status || 'unknown'} ${gateWithQueuedRetest.data.score ?? 'n/a'}/100`);
const completedRetest = retest.data.id
? await patch(`/api/retests/${retest.data.id}`, { status: 'passed', resultSummary: 'Arsenal smoke retest passed.' })
@@ -409,12 +409,12 @@ async function smokeContractsAndLedgers() {
const reproPacks = await post('/api/repro-packs', { operationDraft });
const readyReproPack = (reproPacks.data.packs || []).find(pack => pack.readiness === 'ready');
- record('Repro packs summarize replay readiness', reproPacks.ok && reproPacks.data.schema_version === 'plinyos.t3mp3st_repro_packs/v1' && (reproPacks.data.summary?.total || 0) >= 2 && (reproPacks.data.summary?.ready || 0) >= 1, `${reproPacks.data.summary?.ready || 0}/${reproPacks.data.summary?.total || 0} ready`);
+ record('Repro packs summarize replay readiness', reproPacks.ok && reproPacks.data.schema_version === 't3mp3st.repro_packs/v1' && (reproPacks.data.summary?.total || 0) >= 2 && (reproPacks.data.summary?.ready || 0) >= 1, `${reproPacks.data.summary?.ready || 0}/${reproPacks.data.summary?.total || 0} ready`);
record('Repro pack includes safe replay contract', Boolean(readyReproPack && countItems(readyReproPack.replaySteps) >= 4 && countItems(readyReproPack.falsifiers) >= 3 && readyReproPack.safeProbe), readyReproPack?.id || summarizeError(reproPacks));
const pressurePaths = await post('/api/pressure-paths', { operationDraft });
const bestPressurePath = (pressurePaths.data.paths || [])[0];
- record('Pressure paths convert repro into gated offensive chain', pressurePaths.ok && pressurePaths.data.schema_version === 'plinyos.t3mp3st_pressure_paths/v1' && (pressurePaths.data.summary?.total || 0) >= 2 && (pressurePaths.data.summary?.maxOffensiveScore || 0) > 0, `${pressurePaths.data.summary?.armed || 0}/${pressurePaths.data.summary?.total || 0} armed, max ${pressurePaths.data.summary?.maxOffensiveScore || 0}`);
+ record('Pressure paths convert repro into gated offensive chain', pressurePaths.ok && pressurePaths.data.schema_version === 't3mp3st.pressure_paths/v1' && (pressurePaths.data.summary?.total || 0) >= 2 && (pressurePaths.data.summary?.maxOffensiveScore || 0) > 0, `${pressurePaths.data.summary?.armed || 0}/${pressurePaths.data.summary?.total || 0} armed, max ${pressurePaths.data.summary?.maxOffensiveScore || 0}`);
record('Pressure path includes simulator and no-go gates', Boolean(bestPressurePath && bestPressurePath.safeSimulator?.mode === 'local_canary' && countItems(bestPressurePath.chainStages) >= 5 && countItems(bestPressurePath.noGo) >= 2), bestPressurePath?.id || summarizeError(pressurePaths));
const pressureCanary = await post('/api/pressure-paths/canary', {
@@ -422,7 +422,7 @@ async function smokeContractsAndLedgers() {
pathId: bestPressurePath?.id,
pressurePaths: pressurePaths.data,
});
- record('Pressure canary rehearses top path locally', pressureCanary.ok && pressureCanary.data.schema_version === 'plinyos.t3mp3st_pressure_canary/v1' && pressureCanary.data.status === 'passed' && pressureCanary.data.canary?.mode === 'local_canary', pressureCanary.data.canary?.observedSignal || summarizeError(pressureCanary));
+ record('Pressure canary rehearses top path locally', pressureCanary.ok && pressureCanary.data.schema_version === 't3mp3st.pressure_canary/v1' && pressureCanary.data.status === 'passed' && pressureCanary.data.canary?.mode === 'local_canary', pressureCanary.data.canary?.observedSignal || summarizeError(pressureCanary));
// Honest by design: a local synthetic canary is CONTEXT evidence, never 'replayable'
// proof against the real target (see buildPressureCanary β stamping it 'replayable'
// would let a finding self-promote on fabricated evidence). Assert 'context'.
@@ -434,7 +434,7 @@ async function smokeContractsAndLedgers() {
pressurePaths: pressurePaths.data,
pressureCanary: pressureCanary.data,
});
- record('Pressure duel survives only evidence-backed canary route', pressureDuel.ok && pressureDuel.data.schema_version === 'plinyos.t3mp3st_pressure_duel/v1' && pressureDuel.data.status === 'survived' && (pressureDuel.data.survivabilityScore || 0) >= 86, `${pressureDuel.data.status || 'unknown'} ${pressureDuel.data.survivabilityScore || 0}/100`);
+ record('Pressure duel survives only evidence-backed canary route', pressureDuel.ok && pressureDuel.data.schema_version === 't3mp3st.pressure_duel/v1' && pressureDuel.data.status === 'survived' && (pressureDuel.data.survivabilityScore || 0) >= 86, `${pressureDuel.data.status || 'unknown'} ${pressureDuel.data.survivabilityScore || 0}/100`);
record('Pressure duel writes skeptic evidence and follow-up task', Boolean(pressureDuel.data.evidence?.id && pressureDuel.data.workOrder?.id && countItems(pressureDuel.data.duel?.rounds) >= 3), `${pressureDuel.data.evidence?.id || 'no evidence'} / ${pressureDuel.data.workOrder?.id || 'no work order'}`);
const pressureMutations = await post('/api/pressure-paths/mutate', {
@@ -443,7 +443,7 @@ async function smokeContractsAndLedgers() {
pressurePaths: pressurePaths.data,
pressureDuel: pressureDuel.data,
});
- record('Pressure mutation gauntlet forks survived route', pressureMutations.ok && pressureMutations.data.schema_version === 'plinyos.t3mp3st_pressure_mutations/v1' && pressureMutations.data.status === 'queued' && countItems(pressureMutations.data.mutations) >= 5 && (pressureMutations.data.summary?.maxFangScore || 0) >= 80, `${countItems(pressureMutations.data.mutations)} mutations / max ${pressureMutations.data.summary?.maxFangScore || 0}`);
+ record('Pressure mutation gauntlet forks survived route', pressureMutations.ok && pressureMutations.data.schema_version === 't3mp3st.pressure_mutations/v1' && pressureMutations.data.status === 'queued' && countItems(pressureMutations.data.mutations) >= 5 && (pressureMutations.data.summary?.maxFangScore || 0) >= 80, `${countItems(pressureMutations.data.mutations)} mutations / max ${pressureMutations.data.summary?.maxFangScore || 0}`);
record('Pressure mutation gauntlet queues specialist work orders', Boolean(pressureMutations.data.evidence?.id && countItems(pressureMutations.data.workOrders) >= 3 && pressureMutations.data.workOrders.every(order => order.requiresReceipt === false)), `${pressureMutations.data.evidence?.id || 'no evidence'} / ${countItems(pressureMutations.data.workOrders)} work orders`);
const pressureChains = await post('/api/pressure-paths/chains', {
@@ -452,7 +452,7 @@ async function smokeContractsAndLedgers() {
pressurePaths: pressurePaths.data,
pressureMutations: pressureMutations.data,
});
- record('Pressure fang chains compose local weird-machine routes', pressureChains.ok && pressureChains.data.schema_version === 'plinyos.t3mp3st_pressure_chains/v1' && pressureChains.data.status === 'queued' && countItems(pressureChains.data.chains) >= 3 && (pressureChains.data.summary?.maxChainScore || 0) >= 80, `${countItems(pressureChains.data.chains)} chains / max ${pressureChains.data.summary?.maxChainScore || 0}`);
+ record('Pressure fang chains compose local weird-machine routes', pressureChains.ok && pressureChains.data.schema_version === 't3mp3st.pressure_chains/v1' && pressureChains.data.status === 'queued' && countItems(pressureChains.data.chains) >= 3 && (pressureChains.data.summary?.maxChainScore || 0) >= 80, `${countItems(pressureChains.data.chains)} chains / max ${pressureChains.data.summary?.maxChainScore || 0}`);
record('Pressure fang chains queue staged specialist work orders', Boolean(pressureChains.data.evidence?.id && countItems(pressureChains.data.workOrders) >= 2 && pressureChains.data.workOrders.every(order => order.requiresReceipt === false)), `${pressureChains.data.evidence?.id || 'no evidence'} / ${countItems(pressureChains.data.workOrders)} work orders`);
const evidenceList = await get(`/api/evidence?missionId=${encodeURIComponent(draft.data.id)}`);
@@ -469,15 +469,15 @@ async function smokeContractsAndLedgers() {
record('Retest ledger can be listed', retestList.ok && (retestList.data.retests || []).some(item => item.id === retest.data.id), `${countItems(retestList.data.retests)} retests`);
const bundle = await post('/api/mission-bundles', { operationDraft });
- record('Mission bundle exports handoff', bundle.ok && bundle.data.schema_version === 'plinyos.t3mp3st_mission_bundle/v1', bundle.data.handoff?.humanSummary || summarizeError(bundle));
- record('Mission bundle carries repro packs', bundle.ok && bundle.data.reproPacks?.schema_version === 'plinyos.t3mp3st_repro_packs/v1' && (bundle.data.reproPacks.summary?.total || 0) >= 2, `${bundle.data.reproPacks?.summary?.ready || 0}/${bundle.data.reproPacks?.summary?.total || 0} ready`);
- record('Mission bundle carries pressure paths', bundle.ok && bundle.data.pressurePaths?.schema_version === 'plinyos.t3mp3st_pressure_paths/v1' && (bundle.data.pressurePaths.summary?.total || 0) >= 2, `${bundle.data.pressurePaths?.summary?.armed || 0}/${bundle.data.pressurePaths?.summary?.total || 0} armed`);
+ record('Mission bundle exports handoff', bundle.ok && bundle.data.schema_version === 't3mp3st.mission_bundle/v1', bundle.data.handoff?.humanSummary || summarizeError(bundle));
+ record('Mission bundle carries repro packs', bundle.ok && bundle.data.reproPacks?.schema_version === 't3mp3st.repro_packs/v1' && (bundle.data.reproPacks.summary?.total || 0) >= 2, `${bundle.data.reproPacks?.summary?.ready || 0}/${bundle.data.reproPacks?.summary?.total || 0} ready`);
+ record('Mission bundle carries pressure paths', bundle.ok && bundle.data.pressurePaths?.schema_version === 't3mp3st.pressure_paths/v1' && (bundle.data.pressurePaths.summary?.total || 0) >= 2, `${bundle.data.pressurePaths?.summary?.armed || 0}/${bundle.data.pressurePaths?.summary?.total || 0} armed`);
const bundleByMission = await get(`/api/mission-bundles/${encodeURIComponent(draft.data.id)}`);
- record('Mission bundle can be rebuilt by mission id', bundleByMission.ok && bundleByMission.data.schema_version === 'plinyos.t3mp3st_mission_bundle/v1', bundleByMission.data.handoff?.humanSummary || summarizeError(bundleByMission));
+ record('Mission bundle can be rebuilt by mission id', bundleByMission.ok && bundleByMission.data.schema_version === 't3mp3st.mission_bundle/v1', bundleByMission.data.handoff?.humanSummary || summarizeError(bundleByMission));
const gate = await post('/api/mission-gate', { operationDraft });
- record('Mission gate evaluates ledgers and receipts', gate.ok && gate.data.schema_version === 'plinyos.t3mp3st_mission_gate/v1' && ['hold', 'ready'].includes(gate.data.status), `${gate.data.status || 'unknown'} ${gate.data.score ?? 'n/a'}/100`);
+ record('Mission gate evaluates ledgers and receipts', gate.ok && gate.data.schema_version === 't3mp3st.mission_gate/v1' && ['hold', 'ready'].includes(gate.data.status), `${gate.data.status || 'unknown'} ${gate.data.score ?? 'n/a'}/100`);
const tempDraft = await post('/api/mission-drafts', {
title: 'Arsenal Smoke Delete Fixture',
@@ -561,7 +561,7 @@ async function smokeImprovementAndLearning(context) {
record('Repeated learning review reinforces, not duplicates', repeated.status === 201 && beforeCount === afterCount && reinforced, `${afterCount} proposals / reinforced=${reinforced}`);
const capsule = await get('/api/memory/capsule');
- record('Memory capsule exposes accepted entries only', capsule.ok && capsule.data.schema_version === 'plinyos.t3mp3st_memory_capsule/v1' && (capsule.data.entries || []).some(item => item.id === acceptedMemory.data.entry?.id), `${countItems(capsule.data.entries)} entries`);
+ record('Memory capsule exposes accepted entries only', capsule.ok && capsule.data.schema_version === 't3mp3st.memory_capsule/v1' && (capsule.data.entries || []).some(item => item.id === acceptedMemory.data.entry?.id), `${countItems(capsule.data.entries)} entries`);
}
// NOTE: The "Pliny Specials" backend routes (/api/pliny/leviathan/engage,
diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs
index 68f7875..932c3d3 100644
--- a/scripts/doctor.mjs
+++ b/scripts/doctor.mjs
@@ -120,10 +120,10 @@ async function main() {
const preflight = await apiGet('/api/preflight');
check('Capability preflight', preflight.ok && typeof preflight.data.score === 'number', `${preflight.data.score ?? 'n/a'}/100`);
const arsenal = await apiGet('/api/arsenal/status');
- check('Arsenal status', arsenal.ok && arsenal.data.schema_version === 'plinyos.t3mp3st_arsenal_status/v1', `${arsenal.data.summary?.installedCommandReady ?? 0}/${arsenal.data.summary?.commandReady ?? 0} installed`);
+ check('Arsenal status', arsenal.ok && arsenal.data.schema_version === 't3mp3st.arsenal_status/v1', `${arsenal.data.summary?.installedCommandReady ?? 0}/${arsenal.data.summary?.commandReady ?? 0} installed`);
check('Arsenal does not fake empty coverage', arsenal.ok && arsenal.data.summary?.unmodeled === false, `unmodeled=${arsenal.data.summary?.unmodeled}`);
const activation = await apiGet('/api/arsenal/activation');
- check('Arsenal activation plan', activation.ok && activation.data.schema_version === 'plinyos.t3mp3st_arsenal_activation/v1', `${activation.data.summary?.total || 0} wired / doc ${activation.data.localPlanDoc || 'missing'}`);
+ check('Arsenal activation plan', activation.ok && activation.data.schema_version === 't3mp3st.arsenal_activation/v1', `${activation.data.summary?.total || 0} wired / doc ${activation.data.localPlanDoc || 'missing'}`);
}
const blocks = checks.filter(item => !item.passed && item.severity === 'block');
diff --git a/scripts/exploit-chain-smoke.mjs b/scripts/exploit-chain-smoke.mjs
index 63716a5..95bcdfc 100644
--- a/scripts/exploit-chain-smoke.mjs
+++ b/scripts/exploit-chain-smoke.mjs
@@ -70,7 +70,7 @@ async function main() {
? await post('/api/route-preview', { draftId: draft.data.id })
: { ok: false, data: {} };
const operationDraft = route.data.operationDraft || {};
- record('Route preview emits operation contract', route.ok && operationDraft.schema_version === 'plinyos.t3mp3st_operation/v1', operationDraft.schema_version || route.data.error);
+ record('Route preview emits operation contract', route.ok && operationDraft.schema_version === 't3mp3st.operation/v1', operationDraft.schema_version || route.data.error);
record('Route family is web/API for synthetic local range', route.ok && route.data.route?.family === 'web_api', route.data.route?.family || route.data.error);
const approvalRequest = await post('/api/approvals/request', {
@@ -154,17 +154,17 @@ async function main() {
record('Retest completed for full-chain smoke', completedRetest.ok && completedRetest.data.status === 'passed', completedRetest.data.status || completedRetest.data.error);
const bundle = await post('/api/mission-bundles', { operationDraft });
- record('Mission bundle includes exploit-chain ledgers', bundle.ok && bundle.data.schema_version === 'plinyos.t3mp3st_mission_bundle/v1' && countItems(bundle.data.ledgers?.evidence) > 0 && countItems(bundle.data.ledgers?.findings) > 0, bundle.data.handoff?.humanSummary || bundle.data.error);
+ record('Mission bundle includes exploit-chain ledgers', bundle.ok && bundle.data.schema_version === 't3mp3st.mission_bundle/v1' && countItems(bundle.data.ledgers?.evidence) > 0 && countItems(bundle.data.ledgers?.findings) > 0, bundle.data.handoff?.humanSummary || bundle.data.error);
const gate = await post('/api/mission-gate', { operationDraft });
- record('Mission gate is ready after receipt, evidence, and retest', gate.ok && gate.data.schema_version === 'plinyos.t3mp3st_mission_gate/v1' && gate.data.status === 'ready' && gate.data.score >= 80, `${gate.data.status || 'unknown'} ${gate.data.score ?? 'n/a'}/100`);
+ record('Mission gate is ready after receipt, evidence, and retest', gate.ok && gate.data.schema_version === 't3mp3st.mission_gate/v1' && gate.data.status === 'ready' && gate.data.score >= 80, `${gate.data.status || 'unknown'} ${gate.data.score ?? 'n/a'}/100`);
const learningReview = await post('/api/learning/run-review', {
missionId: operationDraft.mission_id,
operationId: operationDraft.operation_id,
family: operationDraft.family,
});
- record('Learning review proposes inspectable memory', learningReview.status === 201 && learningReview.data.schema_version === 'plinyos.t3mp3st_learning_review/v1' && countItems(learningReview.data.proposals) > 0, `${countItems(learningReview.data.proposals)} proposal(s)`);
+ record('Learning review proposes inspectable memory', learningReview.status === 201 && learningReview.data.schema_version === 't3mp3st.learning_review/v1' && countItems(learningReview.data.proposals) > 0, `${countItems(learningReview.data.proposals)} proposal(s)`);
const proposalId = learningReview.data.proposals?.[0]?.id;
const acceptedMemory = proposalId
@@ -173,7 +173,7 @@ async function main() {
record('Memory proposal can be explicitly accepted', acceptedMemory.ok && acceptedMemory.data.entry?.id && acceptedMemory.data.proposal?.status === 'accepted', acceptedMemory.data.entry?.id || acceptedMemory.data.error);
const capsule = await get('/api/memory/capsule');
- record('Memory capsule exposes accepted entries', capsule.ok && capsule.data.schema_version === 'plinyos.t3mp3st_memory_capsule/v1' && (capsule.data.entries || []).some(entry => entry.id === acceptedMemory.data.entry?.id), `${countItems(capsule.data.entries)} accepted memory entries`);
+ record('Memory capsule exposes accepted entries', capsule.ok && capsule.data.schema_version === 't3mp3st.memory_capsule/v1' && (capsule.data.entries || []).some(entry => entry.id === acceptedMemory.data.entry?.id), `${countItems(capsule.data.entries)} accepted memory entries`);
const proposalListBeforeRepeat = await get('/api/memory/proposals');
const proposalCountBeforeRepeat = countItems(proposalListBeforeRepeat.data.proposals);
diff --git a/scripts/field-drill.mjs b/scripts/field-drill.mjs
index 8102d54..eb6f4d5 100644
--- a/scripts/field-drill.mjs
+++ b/scripts/field-drill.mjs
@@ -90,7 +90,7 @@ async function runScenario(name, scenario) {
const routePreview = await post('/api/route-preview', { draftId: missionDraft.data.id });
record(`[${name}] route family classified`, routePreview.ok && routePreview.data.route?.family === scenario.family, routePreview.data.route?.family || routePreview.data.error);
- record(`[${name}] operation contract emitted`, routePreview.data.operationDraft?.schema_version === 'plinyos.t3mp3st_operation/v1', routePreview.data.operationDraft?.schema_version || 'missing schema');
+ record(`[${name}] operation contract emitted`, routePreview.data.operationDraft?.schema_version === 't3mp3st.operation/v1', routePreview.data.operationDraft?.schema_version || 'missing schema');
record(`[${name}] operation contract carries runbook context`, Boolean(routePreview.data.operationDraft?.knowledge_context?.operator_runbook), routePreview.data.operationDraft?.knowledge_context?.operator_runbook || 'missing runbook context');
const evidence = await post('/api/evidence', {
@@ -135,10 +135,10 @@ async function runScenario(name, scenario) {
record(`[${name}] retest can complete`, completedRetest.ok && completedRetest.data.status === 'passed', completedRetest.data.status || completedRetest.data.error);
const bundle = await post('/api/mission-bundles', { operationDraft: routePreview.data.operationDraft });
- record(`[${name}] mission bundle exports handoff`, bundle.ok && bundle.data.schema_version === 'plinyos.t3mp3st_mission_bundle/v1' && bundle.data.runbook?.family === scenario.family && bundle.data.promptPacks?.some(pack => pack.id === scenario.expectedPromptPack), bundle.data.handoff?.humanSummary || bundle.data.error);
+ record(`[${name}] mission bundle exports handoff`, bundle.ok && bundle.data.schema_version === 't3mp3st.mission_bundle/v1' && bundle.data.runbook?.family === scenario.family && bundle.data.promptPacks?.some(pack => pack.id === scenario.expectedPromptPack), bundle.data.handoff?.humanSummary || bundle.data.error);
const gate = await post('/api/mission-gate', { operationDraft: routePreview.data.operationDraft });
- record(`[${name}] mission gate reports launch status`, gate.ok && gate.data.schema_version === 'plinyos.t3mp3st_mission_gate/v1' && ['hold', 'ready'].includes(gate.data.status), `${gate.data.status || 'unknown'} ${gate.data.score ?? 'n/a'}/100`);
+ record(`[${name}] mission gate reports launch status`, gate.ok && gate.data.schema_version === 't3mp3st.mission_gate/v1' && ['hold', 'ready'].includes(gate.data.status), `${gate.data.status || 'unknown'} ${gate.data.score ?? 'n/a'}/100`);
return { missionDraft, routePreview, evidence, finding, retest };
}
@@ -203,19 +203,19 @@ async function main() {
}
const arsenalStatus = await get('/api/arsenal/status?family=web_api');
- record('Arsenal adapter status reports mission readiness', arsenalStatus.ok && arsenalStatus.data.schema_version === 'plinyos.t3mp3st_arsenal_status/v1' && typeof arsenalStatus.data.summary?.readiness === 'number', `${arsenalStatus.data.summary?.readiness ?? 'n/a'}% ready`);
+ record('Arsenal adapter status reports mission readiness', arsenalStatus.ok && arsenalStatus.data.schema_version === 't3mp3st.arsenal_status/v1' && typeof arsenalStatus.data.summary?.readiness === 'number', `${arsenalStatus.data.summary?.readiness ?? 'n/a'}% ready`);
const arsenalPlan = await post('/api/arsenal/plan', {
family: 'web_api',
target: 'local-lab',
objective: 'Field drill validates gated tool planning.',
});
- record('Arsenal planner emits gated evidence steps', arsenalPlan.ok && arsenalPlan.data.schema_version === 'plinyos.t3mp3st_arsenal_plan/v1' && (arsenalPlan.data.steps || []).length >= 4 && (arsenalPlan.data.stopConditions || []).length >= 2, `${arsenalPlan.data.steps?.length || 0} steps / ${arsenalPlan.data.stopConditions?.length || 0} stops`);
+ record('Arsenal planner emits gated evidence steps', arsenalPlan.ok && arsenalPlan.data.schema_version === 't3mp3st.arsenal_plan/v1' && (arsenalPlan.data.steps || []).length >= 4 && (arsenalPlan.data.stopConditions || []).length >= 2, `${arsenalPlan.data.steps?.length || 0} steps / ${arsenalPlan.data.stopConditions?.length || 0} stops`);
const doctrine = await get('/api/operator-doctrine');
record('Operator doctrine exposes authority model', doctrine.ok && /scope receipts/.test(doctrine.data.doctrine || '') && doctrine.data.authorityModel?.claimHardening === 'hypothesis -> evidence -> finding -> fix -> retest', doctrine.data.authorityModel?.claimHardening || doctrine.data.error);
const learningStatus = await get('/api/learning/status');
- record('Learning loop exposes explicit proposal policy', learningStatus.ok && learningStatus.data.schema_version === 'plinyos.t3mp3st_learning_status/v1' && learningStatus.data.policy?.silentLearning === false && learningStatus.data.policy?.acceptanceRequired === true, learningStatus.data.policy?.proposalFlow || learningStatus.data.error);
+ record('Learning loop exposes explicit proposal policy', learningStatus.ok && learningStatus.data.schema_version === 't3mp3st.learning_status/v1' && learningStatus.data.policy?.silentLearning === false && learningStatus.data.policy?.acceptanceRequired === true, learningStatus.data.policy?.proposalFlow || learningStatus.data.error);
record('Learning loop deduplicates repeated reviews', learningStatus.ok && learningStatus.data.dedupe?.enabled === true && /canonical content fingerprint/.test(learningStatus.data.dedupe?.strategy || ''), learningStatus.data.dedupe?.strategy || learningStatus.data.error);
const runbooks = await get('/api/operator-runbooks');
@@ -223,7 +223,7 @@ async function main() {
const forefrontRadar = await get('/api/forefront-radar');
const laneIds = (forefrontRadar.data.lanes || []).map(lane => lane.id);
- record('Forefront radar exposes pressure lanes', forefrontRadar.ok && forefrontRadar.data.schema_version === 'plinyos.t3mp3st_forefront_radar/v1' && laneIds.includes('frontier-agent-command-injection') && laneIds.includes('frontier-browser-tool-privilege'), laneIds.join(', '));
+ record('Forefront radar exposes pressure lanes', forefrontRadar.ok && forefrontRadar.data.schema_version === 't3mp3st.forefront_radar/v1' && laneIds.includes('frontier-agent-command-injection') && laneIds.includes('frontier-browser-tool-privilege'), laneIds.join(', '));
const cveSearch = await post('/api/resource-packs/search', { query: 'CVE' });
const cveResourceIds = (cveSearch.data.resources || []).map(resource => resource.id);
diff --git a/scripts/refute-finding.mjs b/scripts/refute-finding.mjs
index bb2e4e8..6dbe4dd 100755
--- a/scripts/refute-finding.mjs
+++ b/scripts/refute-finding.mjs
@@ -113,16 +113,264 @@ function parseVerdict(text) {
// refuting a REAL finding. Before counting a REFUTED vote, confirm the cited guard's
// comparison actually appears in the cited source file. Catches the exact failure observed
// on the held wild-hunt targets: real findings refuted via invented or paraphrased-irrelevant guards.
+//
+// CANONICAL LOGIC: src/mission/adjudicate.ts `guardExistsInSource` β this is a self-contained mirror
+// (the CLI must run offline). KEEP THE TWO IN SYNC. Hardened 2026-07-03: the structural fallback now
+// requires the cited operands to be the operands of a REAL comparison in source (not two tokens that
+// merely co-occur on a line), and the substring path requires the quote to actually LOOK like a guard
+// (a comparison / control keyword / bounds-call). Without both, a hallucinated guard whose operand
+// strings happen to appear in source could falsely refute β and permanently dedup-block β a real 0-day.
+// Reduce source to code-only lines. Mirror of codeLinesOf + langTraits in src/mission/adjudicate.ts β
+// keep in sync. Comment/string forms are stripped precisely PER LANGUAGE; the regex-vs-division call is
+// avoided entirely β a regex-bearing language blanks each line past its first `/`, a no-regex language
+// keeps divisions verbatim. See the TS copy's docstring for the full rationale.
+function langTraits(file) {
+ const base = { regexAware: false, slashComment: false, blockComment: false, hashComment: false, dashComment: false, pctComment: false, splice: false, pyTriple: false, template: false, rawBacktick: false, cppRaw: false, rustRaw: false, heredoc: false, podDoc: false, perlQuote: false, dollarQuote: false };
+ const set = (o) => ({ ...base, ...o });
+ const ext = (String(file || '').toLowerCase().match(/\.([a-z0-9+#]+)$/) || [])[1] || '';
+ if (/^(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(ext)) return set({ regexAware: true, slashComment: true, blockComment: true, template: true });
+ if (/^(cpp|cxx|cc|hpp|hh|hxx|c\+\+|ino|cppm|ixx|tpp|ipp)$/.test(ext)) return set({ slashComment: true, blockComment: true, splice: true, cppRaw: true });
+ if (/^(c|h)$/.test(ext)) return set({ slashComment: true, blockComment: true, splice: true });
+ if (/^go$/.test(ext)) return set({ slashComment: true, blockComment: true, rawBacktick: true });
+ if (/^rs$/.test(ext)) return set({ slashComment: true, blockComment: true, rustRaw: true });
+ if (/^(java|kt|kts|scala|swift|cs|dart|groovy|gradle)$/.test(ext)) return set({ slashComment: true, blockComment: true, pyTriple: true });
+ if (/^(py|pyi|pyw|pyx)$/.test(ext)) return set({ hashComment: true, pyTriple: true });
+ if (/^(sh|bash|zsh|ksh)$/.test(ext)) return set({ hashComment: true, heredoc: 'shell' });
+ if (/^(fish|yaml|yml|tf|toml|ini|cfg|conf|dockerfile|mk|cmake|jl|nim|coffee|properties|gitignore)$/.test(ext)) return set({ hashComment: true });
+ if (/^php\d*$/.test(ext)) return set({ slashComment: true, blockComment: true, hashComment: true, heredoc: 'php' });
+ if (/^(sql|hql|psql|pgsql|ddl)$/.test(ext)) return set({ dashComment: true, blockComment: true, dollarQuote: true });
+ if (/^(lua|hs|lhs|elm|adb|ads)$/.test(ext)) return set({ dashComment: true });
+ if (/^(erl|hrl|tex|latex)$/.test(ext)) return set({ pctComment: true });
+ return set({ regexAware: true, slashComment: true, blockComment: true, hashComment: true, dashComment: true, pctComment: true, pyTriple: true, rawBacktick: true, heredoc: 'ruby', podDoc: true, perlQuote: true });
+}
+function codeLinesOf(src, t) {
+ const n = src.length;
+ let masked = '';
+ const heredocs = [];
+ const isIdent = (ch) => ch !== undefined && /[A-Za-z0-9_$]/.test(ch);
+ const blank = (len) => { if (len > 0) masked += ' '.repeat(len); };
+ const emitEsc = (i) => {
+ if (src[i + 1] === '\n') { masked += '\n'; return i + 2; }
+ if (src[i + 1] === '\r' && src[i + 2] === '\n') { masked += '\n'; return i + 3; }
+ masked += ' '; return i + 2;
+ };
+ const skipLineComment = (i, splice) => {
+ for (;;) {
+ while (i < n && src[i] !== '\n') i++;
+ if (splice) { let k = i - 1; if (src[k] === '\r') k--; if (k >= 0 && src[k] === '\\') { masked += '\n'; i++; continue; } }
+ return i;
+ }
+ };
+ const blankToClose = (j, close) => {
+ while (j < n && src.slice(j, j + close.length) !== close) { masked += src[j] === '\n' ? '\n' : ' '; j++; }
+ const end = Math.min(j + close.length, n);
+ blank(end - j);
+ return end;
+ };
+ const PERL_PAIR = { '(': ')', '{': '}', '[': ']', '<': '>' };
+ const scanPerlQuote = (i) => {
+ const m = src.slice(i, i + 3).match(/^(qq|qw|qr|qx|tr|q|m|s|y)(?![A-Za-z0-9_])/);
+ if (!m) return -1;
+ const op = m[1];
+ let j = i + op.length;
+ while (src[j] === ' ' || src[j] === '\t') j++;
+ const open = src[j];
+ if (!open || /[A-Za-z0-9_\s=,;)\]}>]/.test(open)) return -1;
+ const twoBody = op === 's' || op === 'tr' || op === 'y';
+ blank(j - i + 1); j++;
+ const scanBody = (o, cl) => {
+ let depth = 1;
+ while (j < n) {
+ const d = src[j];
+ if (d === '\\') { masked += ' '; masked += src[j + 1] === '\n' ? '\n' : ' '; j += 2; continue; }
+ if (PERL_PAIR[o] && d === o) depth++;
+ else if (d === cl) { depth--; if (depth === 0) { blank(1); j++; return; } }
+ masked += d === '\n' ? '\n' : ' '; j++;
+ }
+ };
+ scanBody(open, PERL_PAIR[open] ?? open);
+ if (twoBody) {
+ let o2 = open, c2 = PERL_PAIR[open] ?? open;
+ if (PERL_PAIR[open]) { while (src[j] === ' ' || src[j] === '\t') j++; o2 = src[j]; c2 = PERL_PAIR[o2] ?? o2; if (o2) { blank(1); j++; } }
+ scanBody(o2, c2);
+ }
+ return j;
+ };
+ const scanTemplate = (i) => {
+ masked += ' '; i++;
+ const stk = ['`'];
+ while (i < n && stk.length > 0) {
+ const d = src[i];
+ if (d === '\\') { i = emitEsc(i); continue; }
+ const top = stk[stk.length - 1];
+ if (top === '`') {
+ if (d === '`') { stk.pop(); masked += ' '; i++; continue; }
+ if (d === '$' && src[i + 1] === '{') { stk.push('{'); masked += ' '; i += 2; continue; }
+ } else if (top === '"' || top === "'") {
+ if (d === top) { stk.pop(); masked += ' '; i++; continue; }
+ if (d === '\n') { stk.pop(); masked += '\n'; i++; continue; }
+ } else { // ${β¦} interpolation β BLANK it (display text, not a bounds guard; and an interpolation
+ if (d === '}') { stk.pop(); masked += ' '; i++; continue; } // reached through a nested template
+ if (d === '{') { stk.push('{'); masked += ' '; i++; continue; } // must never leak β false-VERIFY)
+ if (d === '`') { stk.push('`'); masked += ' '; i++; continue; }
+ if (d === '"' || d === "'") { stk.push(d); masked += ' '; i++; continue; }
+ }
+ masked += d === '\n' ? '\n' : ' '; i++;
+ }
+ return i;
+ };
+ for (let i = 0; i < n; ) {
+ const c = src[i], c2 = src[i + 1];
+ if (t.podDoc && (i === 0 || src[i - 1] === '\n')) {
+ let le = i; while (le < n && src[le] !== '\n') le++;
+ // __END__/__DATA__ must be at column 0 to be a Perl trailer β test from the line's first char (not
+ // a trimmed copy) so an indented one is treated as ordinary code, not over-blanked to EOF.
+ if (/^__(?:END|DATA)__\b/.test(src.slice(i, le))) { while (i < n) { masked += src[i] === '\n' ? '\n' : ' '; i++; } continue; }
+ if (c === '=' && /[A-Za-z]/.test(c2) && /\n=cut\b/.test(src.slice(i))) {
+ for (;;) {
+ let j = i; while (j < n && src[j] !== '\n') j++;
+ const isCut = /^=cut\b/.test(src.slice(i, j));
+ blank(j - i); i = j;
+ if (i < n) { masked += '\n'; i++; }
+ if (isCut || i >= n) break;
+ }
+ continue;
+ }
+ }
+ if (t.perlQuote && (c === 'q' || c === 'm' || c === 's' || c === 'y' || c === 't') && !isIdent(src[i - 1])) {
+ const j = scanPerlQuote(i);
+ if (j >= 0) { i = j; continue; }
+ }
+ if (t.slashComment && c === '/' && c2 === '/') { i = skipLineComment(i, t.splice); continue; }
+ if (t.hashComment && c === '#') { i = skipLineComment(i, false); continue; }
+ if (t.dashComment && c === '-' && c2 === '-') { i = skipLineComment(i, false); continue; }
+ if (t.pctComment && c === '%') { i = skipLineComment(i, false); continue; }
+ if (t.blockComment && c === '/' && c2 === '*') {
+ i += 2; while (i < n && !(src[i] === '*' && src[i + 1] === '/')) { if (src[i] === '\n') masked += '\n'; i++; } i += 2; continue;
+ }
+ if (t.cppRaw && !isIdent(src[i - 1])) {
+ const m = src.slice(i, i + 4).match(/^(u8R|uR|UR|LR|R)"/);
+ if (m) {
+ let j = i + m[0].length, delim = '';
+ while (j < n && src[j] !== '(' && src[j] !== '\n' && src[j] !== ' ' && src[j] !== '\\' && delim.length <= 16) { delim += src[j]; j++; }
+ if (src[j] === '(') { blank(j - i + 1); i = blankToClose(j + 1, ')' + delim + '"'); continue; }
+ }
+ }
+ if (t.rustRaw && !isIdent(src[i - 1])) {
+ const m = src.slice(i, i + 260).match(/^b?r(#*)"/);
+ if (m) { blank(m[0].length); i = blankToClose(i + m[0].length, '"' + m[1]); continue; }
+ }
+ if (t.heredoc === 'php' && c === '<' && c2 === '<' && src[i + 2] === '<') { // PHP << 0) {
+ const hd = heredocs.shift();
+ for (;;) {
+ let j = i; while (j < n && src[j] !== '\n') j++;
+ const stripped = src.slice(i, j).replace(/\r$/, '');
+ blank(j - i); i = j;
+ const isTerm = hd.php ? new RegExp('^[ \\t]*' + hd.w + '(?![A-Za-z0-9_])').test(stripped)
+ : (hd.indent ? stripped.trim() === hd.w : stripped === hd.w);
+ if (i < n) { masked += '\n'; i++; }
+ if (isTerm || i >= n) break;
+ }
+ }
+ continue;
+ }
+ masked += c; i++;
+ }
+ const lines = masked.split('\n');
+ if (!t.regexAware) return lines;
+ return lines.map((line) => {
+ const first = line.indexOf('/');
+ return first === -1 ? line : line.slice(0, first) + ' '.repeat(line.length - first);
+ });
+}
function guardExistsInSource(g, repoRoot) {
if (!g || !g.file || !g.quote) return 'no-quote';
let src;
try { src = fs.readFileSync(path.join(repoRoot, g.file), 'utf8'); } catch { return 'file-missing'; }
const norm = (s) => String(s).replace(/\s+/g, '').toLowerCase();
- if (norm(g.quote).length >= 6 && norm(src).includes(norm(g.quote))) return 'verified';
- const cmp = String(g.quote).match(/([\w.()>_-]+)\s*(>=|<=|==|!=|>|<)\s*([\w.()>_-]+)/);
- if (cmp) {
- const a = norm(cmp[1]), b = norm(cmp[3]);
- for (const line of src.split('\n')) { const nl = norm(line); if (a && b && nl.includes(a) && nl.includes(b)) return 'verified'; }
+ const coreToken = (s) => norm(s).replace(/^[($@%\s]+/, '').replace(/[)\s;,]+$/, '');
+ const CMP = '([\\w.()\\[\\]>_$@-]+)\\s*(>=|<=|==|!=|>|<)\\s*([\\w.()\\[\\]>_$@-]+)';
+ const GUARD_SHAPE = /(?:>=|<=|==|!=)|\b(?:if|else|return|break|continue|throw|goto)\b|\b(?:assert|clamp|min|max|bound|bounded|validate|sanitize|require|abort|reject|checked?|verify|ensure|limit)\s*\(/i;
+ // reduce to code-only lines (strip comments + BLANK string/template-literal contents), match PER
+ // line β so a guard that lives only in a comment or string can't false-verify, a `//` inside a
+ // string isn't mistaken for a comment, and a quote can't stitch across two unrelated lines.
+ const codeLines = codeLinesOf(src, langTraits(g.file));
+ const q = norm(g.quote);
+ if (q.length >= 6 && GUARD_SHAPE.test(g.quote) && codeLines.some((l) => norm(l).includes(q))) return 'verified';
+ const MIRROR = { '>': '<', '<': '>', '>=': '<=', '<=': '>=', '==': '==', '!=': '!=' };
+ const cited = String(g.quote).match(new RegExp(CMP));
+ if (cited) {
+ const a = coreToken(cited[1]), b = coreToken(cited[3]), op = cited[2];
+ if (a && b && a !== b) {
+ for (const line of codeLines) {
+ const re = new RegExp(CMP, 'g');
+ let m;
+ while ((m = re.exec(line)) !== null) {
+ const la = coreToken(m[1]), lb = coreToken(m[3]), lop = m[2];
+ // A source `<` whose right operand is immediately followed by `,`, `>`, or `<` opens a GENERIC
+ // argument list (`Map`, `Vec`, `Nested>`), not a comparison β skip it.
+ if (lop === '<') {
+ const nextCh = (line.slice(re.lastIndex).match(/^\s*(\S)/) ?? [])[1];
+ if (nextCh === ',' || nextCh === '>' || nextCh === '<') continue;
+ }
+ // operands AND operator must match β same order, or reversed with a mirrored operator.
+ // The operator is the whole meaning of a bounds guard: a cited `idx >= len` must not verify
+ // against a source `idx == len`.
+ if (la === a && lb === b && lop === op) return 'verified';
+ if (la === b && lb === a && lop === MIRROR[op]) return 'verified';
+ }
+ }
+ }
}
return 'hallucinated';
}
@@ -137,6 +385,20 @@ function selfTest() {
ok('parseVerdict extracts JSON', parseVerdict('blah {"verdict":"SURVIVED","killing_guard":null,"why":"none"} tail')?.verdict === 'SURVIVED');
ok('parseVerdict ignores prose braces, takes last verdict', parseVerdict('I see memcpy(&info, x) {note} reasoning...\n```json\n{"verdict":"REFUTED","killing_guard":{"file":"a.c","line":5,"quote":"if(n>cap)return"},"why":"bounded"}\n```')?.verdict === 'REFUTED');
ok('parseVerdict returns null on reasoning-only (no JSON)', parseVerdict('Let me trace the path: 1. UDP reception 2. routing β truncated') === null);
+ // cite-check mirror parity with src/mission/adjudicate.ts (regex-literal tokenizing + operator match).
+ // Each case fails under the OLD logic and passes under the fixed logic β pins the port faithfully.
+ const _tmp = fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'refute-st-'));
+ const _fix = (name, body) => { fs.writeFileSync(path.join(_tmp, name), body); return name; };
+ const _gv = (file, quote) => guardExistsInSource({ file, quote }, _tmp);
+ ok('cite: guard after a quote-bearing regex still verifies',
+ _gv(_fix('a.js', "s = s.replace(/'/g, 'x');\nif (userId >= tenant.limit) throw e;\n"), 'userId >= tenant.limit') === 'verified');
+ ok('cite: a comparison only inside a regex is NOT verified',
+ _gv(_fix('b.js', 'const re = /idx >= len/; return arr[idx];\n'), 'idx >= len') === 'hallucinated');
+ ok('cite: wrong operator (cited >= vs source ==) is NOT verified',
+ _gv(_fix('c.c', 'if (idx == len) return -1;\nreturn arr[idx];\n'), 'idx >= len') === 'hallucinated');
+ ok('cite: reversed-order mirror (cited cap < n vs source n > cap) verifies',
+ _gv(_fix('d.c', 'if (n > cap) return;\n'), 'cap < n') === 'verified');
+ fs.rmSync(_tmp, { recursive: true, force: true });
console.log(`\n${fail === 0 ? 'β ALL PASS' : `β ${fail} FAILED`} β ${pass}/${pass + fail}\n`);
process.exit(fail === 0 ? 0 : 1);
}
diff --git a/src/__tests__/adapter-tools.test.ts b/src/__tests__/adapter-tools.test.ts
new file mode 100644
index 0000000..d9f30f1
--- /dev/null
+++ b/src/__tests__/adapter-tools.test.ts
@@ -0,0 +1,193 @@
+/**
+ * Kali+ adapter β CustomTool factory (Phase-1).
+ * Pins the load-bearing safety + behavior contract:
+ * - catalog_only / import_only adapters are NEVER minted (metasploit/hydra/bloodhound stay off the
+ * callable surface) β the factory returns null.
+ * - a missing binary DEGRADES (returns {success:false, error:}) instead of throwing.
+ * - an out-of-scope target is refused (SCOPE DENIED) BEFORE the subprocess runs.
+ * - a mintable adapter produces a CustomTool with a working name + category + real stdout output.
+ * - buildAdapterTools drops non-mintable adapters and skips already-registered tool names.
+ * All deps are injected fakes β no real binaries are spawned.
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ adapterToCustomTool,
+ buildAdapterTools,
+ toolNameFor,
+ type AdapterToolDeps,
+ type SubprocessResult,
+} from '../arsenal/adapter-tools.js';
+import { TOOL_ADAPTERS } from '../arsenal/catalog.js';
+import type { ToolAdapter } from '../arsenal/catalog.js';
+import type { CustomTool, ToolContext } from '../types/index.js';
+
+function adapter(id: string): ToolAdapter {
+ const a = TOOL_ADAPTERS.find(x => x.id === id);
+ if (!a) throw new Error(`test fixture: adapter '${id}' not found in catalog`);
+ return a;
+}
+
+/** Mint an adapter and assert (for the type-checker and the test) that it was not gated to null. */
+function mint(id: string, deps: AdapterToolDeps): CustomTool {
+ const tool = adapterToCustomTool(adapter(id), deps);
+ expect(tool, `${id} should be mintable`).not.toBeNull();
+ return tool as CustomTool;
+}
+
+/** A deps double that records subprocess spawns and lets each behavior be configured per test. */
+function makeDeps(overrides: Partial = {}): AdapterToolDeps & { spawns: string[][] } {
+ const spawns: string[][] = [];
+ return {
+ spawns,
+ isToolAvailable: async () => true,
+ runSubprocess: async (_command, args): Promise => {
+ spawns.push(args);
+ return { stdout: 'FAKE_OUTPUT', stderr: '', exitCode: 0 };
+ },
+ ...overrides,
+ };
+}
+
+const ctx = (parameters: Record): ToolContext => ({ parameters });
+
+describe('adapterToCustomTool β mint gate', () => {
+ it('NEVER mints catalog_only / import_only adapters (metasploit, hydra, bloodhound β null)', () => {
+ const deps = makeDeps();
+ expect(adapterToCustomTool(adapter('metasploit'), deps)).toBeNull(); // catalog_only
+ expect(adapterToCustomTool(adapter('hydra'), deps)).toBeNull(); // catalog_only
+ expect(adapterToCustomTool(adapter('bloodhound'), deps)).toBeNull(); // import_only
+ });
+
+ it('mints a command-ready adapter with a working name + category', () => {
+ const tool = mint('nmap', makeDeps());
+ expect(tool.name).toBe(toolNameFor(adapter('nmap')));
+ expect(tool.name).toBe('nmap_tool');
+ expect(tool.category).toBe('network'); // passthrough of the catalog category
+ expect(typeof tool.handler).toBe('function');
+ });
+});
+
+describe('adapterToCustomTool β degrade / scope / execution', () => {
+ it('a missing binary DEGRADES (does not throw) and surfaces the installHint', async () => {
+ const deps = makeDeps({ isToolAvailable: async () => false });
+ const tool = mint('nmap', deps);
+ const res = await tool.handler(ctx({ target: '127.0.0.1' }));
+ expect(res.success).toBe(false);
+ expect(res.error).toContain(adapter('nmap').installHint);
+ expect(deps.spawns.length).toBe(0); // nothing was spawned
+ });
+
+ it('scopeOk=false β SCOPE DENIED before the subprocess runs', async () => {
+ const deps = makeDeps({ scopeOk: () => false });
+ const tool = mint('nuclei', deps);
+ const res = await tool.handler(ctx({ url: 'https://evil.example.com' }));
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/SCOPE DENIED/);
+ expect(res.error).toContain('evil.example.com');
+ expect(deps.spawns.length).toBe(0); // refused before spawning
+ });
+
+ it('scopeOk=true β runs the subprocess and returns stdout as output', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ const tool = mint('nuclei', deps);
+ const res = await tool.handler(ctx({ url: 'https://target.example.com' }));
+ expect(res.success).toBe(true);
+ expect(res.output).toBe('FAKE_OUTPUT');
+ expect(deps.spawns.length).toBe(1);
+ // nuclei template threads the target through -target
+ expect(deps.spawns[0]).toContain('https://target.example.com');
+ });
+
+ it('a networked adapter with no target degrades gracefully (no spawn)', async () => {
+ const deps = makeDeps();
+ const tool = mint('nuclei', deps);
+ const res = await tool.handler(ctx({}));
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/requires a target/);
+ expect(deps.spawns.length).toBe(0);
+ });
+
+ it('a non-zero exit code is reported as failure, not a throw', async () => {
+ const deps = makeDeps({
+ scopeOk: () => true,
+ runSubprocess: async () => ({ stdout: '', stderr: 'boom', exitCode: 2 }),
+ });
+ const tool = mint('nikto', deps);
+ const res = await tool.handler(ctx({ url: 'https://target.example.com' }));
+ expect(res.success).toBe(false);
+ expect(res.error).toContain('boom');
+ });
+});
+
+describe('adapterToCustomTool β argument-injection hardening', () => {
+ it('nmap IGNORES an LLM-supplied free-form flags param (no -oN/--script reaches argv)', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ const tool = mint('nmap', deps);
+ const res = await tool.handler(ctx({
+ target: 'target.example.com',
+ flags: '-oN /tmp/pwned --script vuln,exploit', // injection attempt
+ }));
+ expect(res.success).toBe(true);
+ const argv = deps.spawns[0];
+ expect(argv).toEqual(['-sV', '-T4', 'target.example.com']); // flags hardcoded; injection dropped
+ expect(argv).not.toContain('-oN');
+ expect(argv.join(' ')).not.toContain('--script');
+ });
+
+ it('nmap accepts a clean port spec but drops one carrying an injected flag', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ const tool = mint('nmap', deps);
+ await tool.handler(ctx({ target: 'target.example.com', ports: '22,80,443' }));
+ expect(deps.spawns[0]).toEqual(['-sV', '-T4', '-p', '22,80,443', 'target.example.com']);
+ await tool.handler(ctx({ target: 'target.example.com', ports: '80 -oN /tmp/x' }));
+ expect(deps.spawns[1]).toEqual(['-sV', '-T4', 'target.example.com']); // malicious ports dropped
+ });
+
+ it('refuses an option-looking target (leading dash) before spawning', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ const tool = mint('nmap', deps);
+ const res = await tool.handler(ctx({ target: '-oN/tmp/pwned' }));
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/option-looking target/);
+ expect(deps.spawns.length).toBe(0); // never spawned
+ });
+
+ it('curl sends a literal body via --data-raw and REFUSES a local-file-read data value (-d @file)', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ const tool = mint('curl', deps);
+ // a clean body is sent verbatim with --data-raw (never plain -d, which would file-interpret @/<)
+ const ok = await tool.handler(ctx({ url: 'https://target.example.com/', data: 'a=1&b=2' }));
+ expect(ok.success).toBe(true);
+ expect(deps.spawns[0]).toContain('--data-raw');
+ expect(deps.spawns[0]).not.toContain('-d');
+ // `@/etc/passwd` (and ` {
+ it('drops non-mintable adapters (metasploit/hydra/bloodhound never appear)', () => {
+ const tools = buildAdapterTools(TOOL_ADAPTERS, makeDeps());
+ const names = tools.map(t => t.name);
+ expect(names).toContain(toolNameFor(adapter('nmap')));
+ expect(names).not.toContain(toolNameFor(adapter('metasploit')));
+ expect(names).not.toContain(toolNameFor(adapter('hydra')));
+ expect(names).not.toContain(toolNameFor(adapter('bloodhound')));
+ // count matches the catalog's mintable population
+ const mintable = TOOL_ADAPTERS.filter(
+ a => a.execution === 'safe_command' || a.execution === 'receipt_required'
+ ).length;
+ expect(tools.length).toBe(mintable);
+ });
+
+ it('skips adapters whose minted name is already registered', () => {
+ const already = new Set([toolNameFor(adapter('nmap'))]);
+ const tools = buildAdapterTools(TOOL_ADAPTERS, makeDeps(), already);
+ expect(tools.map(t => t.name)).not.toContain(toolNameFor(adapter('nmap')));
+ });
+});
diff --git a/src/__tests__/adjudicate.test.ts b/src/__tests__/adjudicate.test.ts
new file mode 100644
index 0000000..ff32ae8
--- /dev/null
+++ b/src/__tests__/adjudicate.test.ts
@@ -0,0 +1,507 @@
+/**
+ * Shared adjudicate + cite-check (Phase-0 #5) β the two safety primitives lifted from
+ * scripts/refute-finding.mjs into a pure src/mission/adjudicate.ts so the disclosure CLI
+ * and the live pack-audit path can't drift (the pack-hunt design, HT-2).
+ *
+ * Pins: strict-majority thresholds; a MINORITY refute does NOT flip; ties don't refute;
+ * a REFUTED whose killing_guard fails guardExistsInSource is downgraded to SURVIVED;
+ * null/abstain handling never counts as a refute.
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ adjudicate,
+ guardExistsInSource,
+ downgradeUnverifiedRefutes,
+ type RefuterVote,
+} from '../mission/adjudicate.js';
+
+const refuted = (guard?: { file: string; line: number; quote: string }): RefuterVote => ({
+ verdict: 'REFUTED',
+ killing_guard: guard ?? null,
+});
+const survived = (): RefuterVote => ({ verdict: 'SURVIVED', killing_guard: null });
+
+describe('adjudicate β strict-majority tally', () => {
+ it('2 of 3 REFUTED β REFUTED (strict majority)', () => {
+ const a = adjudicate([refuted(), refuted(), survived()]);
+ expect(a.verdict).toBe('REFUTED');
+ expect(a.refutedCount).toBe(2);
+ expect(a.total).toBe(3);
+ });
+
+ it('1 of 3 REFUTED β SURVIVED (a MINORITY refute does NOT flip)', () => {
+ const a = adjudicate([refuted(), survived(), survived()]);
+ expect(a.verdict).toBe('SURVIVED');
+ expect(a.refutedCount).toBe(1);
+ });
+
+ it('a TIE does not refute β 1 of 2 REFUTED β SURVIVED (need STRICT majority)', () => {
+ expect(adjudicate([refuted(), survived()]).verdict).toBe('SURVIVED');
+ });
+
+ it('a TIE does not refute β 2 of 4 REFUTED β SURVIVED', () => {
+ expect(adjudicate([refuted(), refuted(), survived(), survived()]).verdict).toBe('SURVIVED');
+ });
+
+ it('3 of 4 REFUTED β REFUTED', () => {
+ expect(adjudicate([refuted(), refuted(), refuted(), survived()]).verdict).toBe('REFUTED');
+ });
+
+ it('unanimous single REFUTED β REFUTED; single SURVIVED β SURVIVED', () => {
+ expect(adjudicate([refuted()]).verdict).toBe('REFUTED');
+ expect(adjudicate([survived()]).verdict).toBe('SURVIVED');
+ });
+
+ it('collects killing guards from the counted REFUTED votes (nulls dropped)', () => {
+ const g = { file: 'x.c', line: 9, quote: 'if (n > cap) return' };
+ const a = adjudicate([refuted(g), refuted(g), survived()]);
+ expect(a.killing_guards).toHaveLength(2);
+ // a REFUTED with a null guard contributes no entry
+ const b = adjudicate([refuted(g), refuted(), refuted()]);
+ expect(b.killing_guards).toHaveLength(1);
+ });
+});
+
+describe('adjudicate β null / abstain handling', () => {
+ it('null and junk verdicts are dropped: neither refute count nor panel size', () => {
+ // 1 real REFUTED, plus a null and a junk vote β panel size 1, not a majority-of-3
+ const a = adjudicate([refuted(), null, { verdict: 'MAYBE' } as RefuterVote]);
+ expect(a.total).toBe(1);
+ expect(a.refutedCount).toBe(1);
+ expect(a.verdict).toBe('REFUTED'); // 1 of 1 valid
+ });
+
+ it('abstentions cannot manufacture a majority β 1 REFUTED + 2 abstain among 2 survived stays SURVIVED', () => {
+ const a = adjudicate([refuted(), survived(), survived(), null, { verdict: 'junk' } as RefuterVote]);
+ expect(a.total).toBe(3);
+ expect(a.verdict).toBe('SURVIVED');
+ });
+
+ it('empty panel β INCONCLUSIVE', () => {
+ expect(adjudicate([]).verdict).toBe('INCONCLUSIVE');
+ expect(adjudicate([null, undefined, { verdict: 'junk' } as RefuterVote]).verdict).toBe('INCONCLUSIVE');
+ expect(adjudicate([null]).total).toBe(0);
+ });
+});
+
+describe('guardExistsInSource β deterministic cite-check', () => {
+ const source = [
+ 'void copy(size_t n) {',
+ ' if (n > cap) return; // the real killing guard',
+ ' memcpy(dst, src, n);',
+ '}',
+ ].join('\n');
+
+ it('verifies an exact quote (normalized substring, whitespace-insensitive)', () => {
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, source)).toBe(true);
+ // reformatted (extra spaces / different indent) still verifies
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if(n>cap) return' }, source)).toBe(true);
+ });
+
+ it('verifies a reversed-order comparison via the structural fallback (operator mirrored)', () => {
+ // not a substring (operands swapped), but source `n > cap` β‘ cited `cap < n` β same guard, mirrored.
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap < n' }, source)).toBe(true);
+ // same operands + same operator, just reformatted, also verifies structurally.
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n>cap' }, source)).toBe(true);
+ });
+
+ it('REJECTS a same-operand comparison with the WRONG operator (a different guard, not a paraphrase)', () => {
+ // source guard is `n > cap`; a cited `n >= cap` is a DIFFERENT bound (it also blocks n==cap) and
+ // must NOT verify just because it shares the operands β the operator carries the guard's meaning.
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n >= cap' }, source)).toBe(false);
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n == cap' }, source)).toBe(false);
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap <= n' }, source)).toBe(false); // wrong mirror
+ });
+
+ // --- regex-literal tokenizing (adversarial review sixth pass): a quote INSIDE a regex must not open
+ // a phantom string that swallows a real guard on a later line. ---
+ it('a quote inside a regex literal does NOT blank a real guard on a later line', () => {
+ const src = [
+ `function handle(idx, buf) {`,
+ ` const cleaned = name.replace(/['"]/g, '');`, // ubiquitous sanitizer β quotes live in the regex
+ ` if (idx >= buf.length) return ERR;`, // the real killing guard, on the NEXT line
+ ` return buf[idx];`,
+ `}`,
+ ].join('\n');
+ expect(guardExistsInSource({ file: 'a.js', line: 3, quote: 'idx >= buf.length' }, src)).toBe(true);
+ });
+
+ it('an apostrophe-bearing regex does NOT blank a downstream comparison guard', () => {
+ const src = [
+ `s = s.replace(/'/g, "x");`, // a stray apostrophe inside the regex
+ `if (userId >= tenant.limit) throw new Error('scope');`, // the real guard must still verify
+ ].join('\n');
+ expect(guardExistsInSource({ file: 'a.js', line: 2, quote: 'userId >= tenant.limit' }, src)).toBe(true);
+ });
+
+ it('still REJECTS a comparison that lives ONLY inside a regex literal (no false-verify)', () => {
+ // the regex body is blanked like a string, so a hallucinated guard cannot hide there.
+ const src = `const re = /idx >= len/; return arr[idx];`;
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, src)).toBe(false);
+ });
+
+ // --- seventh-pass BLOCKER: a regex in VALUE position (return/typeof/close-paren) must NOT leak its
+ // body β else a comparison that only lives inside the regex false-verifies a hallucinated guard. ---
+ it('a value-position regex body cannot false-verify a hallucinated guard (return / if(x) /re/)', () => {
+ expect(guardExistsInSource({ file: 'a.ts', line: 3, quote: 'm_id >= ls.w' },
+ 'const cell = ls.cells[m_id];\nreturn /m_id >= ls.w/.test(String(m_id));')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a >= b' }, 'return /a >= b/.test(x);')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a >= b' }, 'if (x) /a >= b/.test(y);')).toBe(false);
+ });
+
+ it('over-blanks a JS guard sharing a line AFTER a regex (safe: SURVIVED), keeps it in a no-regex language', () => {
+ // A regex-bearing language (JS): everything after a regex `/` on the line is conservatively blanked
+ // to end-of-line, so this same-line-after-regex guard does NOT verify β a false-REJECT (β the
+ // finding SURVIVES), the module's safe direction. The realistic case (guard on its OWN line) still
+ // verifies (covered by the tests above).
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx < len' },
+ `function f(s, idx, len) { return /['"]/.test(s) && idx < len; }`)).toBe(false);
+ // A no-regex language (C/C++/β¦) keeps every `/` as division, so a real comparison sharing a line
+ // with a division verifies at full fidelity.
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < len' },
+ 'int g(int idx, int len) { int r = a / b; if (idx < len) return r; }')).toBe(true);
+ });
+
+ it('a single division on the guard line is preserved (idx >= len / 2)', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len / 2' }, 'if (idx >= len / 2) return;')).toBe(true);
+ });
+
+ // --- 8th-pass BLOCKER: a DIVISION `/` earlier on a line must not mis-pair with a later regex's
+ // opening `/` (which would leak the regex body) or with a comment's `/` (leaking the comment). ---
+ it('a division before a regex does not leak the regex body (no mis-pairing false-verify)', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'y = a / b; const re = /idx >= len/.test(z);')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'y = foo() / b; const re = /idx >= len/.test(z);')).toBe(false);
+ });
+
+ it('a division before a comment does not leak the comment body', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'lo <= hi' }, 'y = w / 2; // lo <= hi')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'lo <= hi' }, 'y = w / 2; /* lo <= hi */ z = 0;')).toBe(false);
+ });
+
+ it('a division after a CALL is preserved, and a regex after a CONTROL-flow ) is blanked', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx < getLen() / 2' }, 'if (idx < getLen() / 2) return;')).toBe(true); // call-div kept
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'if (x) /idx >= len/.test(y);')).toBe(false); // control-) regex blanked
+ });
+
+ // --- 9th-pass BLOCKERS: a regex after a block-closing `}`, after a keyword-as-PROPERTY, or after a
+ // block comment must not leak its body (the position-heuristic lexers all missed these). ---
+ it('a regex after a block-closing } does not leak its body', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, '{ let a = 1; } /idx > len/.test(input);')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'if (c) { work(); } /idx > len/.exec(s);')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'function h(){ return 0; } /idx > len/.test(q);')).toBe(false);
+ });
+
+ it('a regex after a keyword used as a PROPERTY (o.in / c) does not leak (no division mis-pair)', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'const n = o.in / c; return raw.replace(/idx > len/g,"");')).toBe(false);
+ });
+
+ it('a regex after a block comment does not leak its body', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, '/* note */ const re = /idx > len/.test(s);')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'if /*c*/ (x) /idx > len/.test(y);')).toBe(false);
+ });
+
+ it('a regex containing BOTH a comparison and a quote does not leak (/a > b\'/)', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, "z = /a > b'/.test(s);")).toBe(false);
+ });
+
+ // --- 10th-pass BLOCKERS (fixed by deciding regex-vs-division by LANGUAGE, not per-`/`): a regex-
+ // internal quote must not consume a later real string's quote, and a `\`-continued string must
+ // not leak its comparison. ---
+ it('a regex-internal quote does not consume a later string, leaking its comparison', () => {
+ expect(guardExistsInSource({ file: 'x.ts', line: 1, quote: 'b > c' }, "const r = /a'/.test(x) ? y : 'b > c';")).toBe(false);
+ expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'm_id >= ls.w' }, "if (/x'/.test(name)) log('m_id >= ls.w');")).toBe(false);
+ });
+
+ it('a backslash-continued string does not leak its comparison (JS and C)', () => {
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'var s = "boundary idx >= len \\\nmore"; buf[idx]=v;')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= len' }, 'char* s = "boundary idx >= len \\\nmore"; buf[idx]=v;')).toBe(false);
+ });
+
+ it('a no-regex language (.c) keeps divisions at full fidelity (a division never blanks a real guard)', () => {
+ // In C there are no `/β¦/` regex literals, so `/` is always division; a real bounds guard sharing a
+ // line with a division still verifies β no over-blank, unlike the regex-bearing (JS) path.
+ expect(guardExistsInSource({ file: 'los.c', line: 2, quote: 'm_id >= ls_w' }, 'int cell(int m_id, int ls_w) {\n if (m_id >= ls_w) return -1; return buf[m_id] / scale;\n}')).toBe(true);
+ });
+
+ // --- 11th-pass BLOCKERS: multi-language comment / string handling (the tokenizer is per-language). ---
+ it('a JS nested template literal does not leak its inner string as code', () => {
+ expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'a > b' }, 't = `${`a > b`}`;')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'idx >= len' }, 'h = `${`if (idx >= len) continue`}`;')).toBe(false);
+ });
+
+ it('a \\-continued string does not leak across the newline (LF and CRLF)', () => {
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= len' }, 'char* s = "x idx >= len \\\nmore"; return buf[idx];')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'var s = "x idx >= len \\\r\nmore"; buf[idx]=v;')).toBe(false);
+ });
+
+ it('Python: # comments and triple-quoted strings are stripped, but // is floor-division (kept)', () => {
+ expect(guardExistsInSource({ file: 'a.py', line: 1, quote: 'idx >= len' }, 'x = 1 # idx >= len is the intended check\nreturn buf[idx]')).toBe(false); // # comment
+ expect(guardExistsInSource({ file: 'a.py', line: 2, quote: 'idx >= len' }, 'def f():\n """note: idx >= len denotes overflow"""\n return buf[idx]')).toBe(false); // triple-quote
+ expect(guardExistsInSource({ file: 'a.py', line: 2, quote: 'idx >= n' }, 'h = a // b\nif idx >= n: return -1')).toBe(true); // // is floor-div, guard verifies
+ });
+
+ it('a C backslash-continued // comment does not leak onto the next line', () => {
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'idx >= len' }, 'int f() { // cont \\\nidx >= len leaks?\n return 0; }')).toBe(false);
+ });
+
+ it('# (shell) and -- (SQL/Lua) line comments are stripped for those languages', () => {
+ expect(guardExistsInSource({ file: 'run.sh', line: 1, quote: 'idx >= len' }, 'echo hi # idx >= len check needed')).toBe(false);
+ expect(guardExistsInSource({ file: 'q.sql', line: 1, quote: 'a >= b' }, 'select 1; -- a >= b intended')).toBe(false);
+ });
+
+ // --- 12th-pass: raw-string forms (Go / C++ / Rust) must not leak their body ---
+ it('raw-string literals (Go backtick, C++ R"(...)", Rust r#"..."#) do not leak a comparison', () => {
+ expect(guardExistsInSource({ file: 'x.go', line: 1, quote: 'idx >= len' }, 'q := `SELECT * WHERE idx >= len`\nreturn buf[idx]')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.cpp', line: 1, quote: 'n > cap' }, 'auto s = R"(payload n > cap here)";\nint real = 0;')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.cpp', line: 1, quote: 'n > cap' }, 'auto s = R"xy(has a \\" then n > cap here)xy"; int r = 0;')).toBe(false); // inner-quote parity
+ expect(guardExistsInSource({ file: 'x.rs', line: 1, quote: 'n > cap' }, 'let s = r#"note n > cap and a " quote"#; let r = 0;')).toBe(false);
+ // and real guards in those languages still verify
+ expect(guardExistsInSource({ file: 'x.go', line: 1, quote: 'idx >= len' }, 'if idx >= len { return }')).toBe(true);
+ expect(guardExistsInSource({ file: 'x.rs', line: 1, quote: 'n > cap' }, 'if n > cap { return; }')).toBe(true);
+ });
+
+ it('heredoc bodies (shell < {
+ expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'x > y' }, 'cat < y )); then :; fi\nEOF\necho done')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'a >= b' }, 'cat <<-EOF\n\ta >= b here\n\tEOF\n:')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'i >= len' }, 't = <<~SQL\n if i >= len then end\nSQL\nputs t')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.sh', line: 4, quote: 'n > cap' }, 'cat < cap )); then :; fi')).toBe(true); // code after heredoc
+ expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'n > cap' }, 'arr << item\nif n > cap then end')).toBe(true); // `<<` left-shift, not a heredoc
+ });
+
+ it('ambiguous extensions (.m, .pl) use the safe superset β a % comment cannot leak', () => {
+ expect(guardExistsInSource({ file: 'x.m', line: 1, quote: 'a >= b' }, 'y = 1 % a >= b intended check\nz = 2')).toBe(false); // MATLAB %
+ expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a >= b' }, 'foo :- bar. % a >= b\nbaz.')).toBe(false); // Prolog %
+ expect(guardExistsInSource({ file: 'x.rb', line: 1, quote: 'idx >= len' }, 'x = %q(note idx >= len here)\ny = 1')).toBe(false); // Ruby %q over-blanked
+ });
+
+ it('the `\\`-line-comment splice fires ONLY for C/C++ (JS/Python `//`/`#` do not splice)', () => {
+ expect(guardExistsInSource({ file: 'x.c', line: 2, quote: 'idx >= len' }, 'int f() { // cont \\\nidx >= len leaks?\n return 0; }')).toBe(false); // C splices
+ expect(guardExistsInSource({ file: 'x.js', line: 2, quote: 'idx >= len' }, 'foo(); // note ends with \\\nif (idx >= len) return;')).toBe(true); // JS does NOT splice
+ expect(guardExistsInSource({ file: 'x.py', line: 2, quote: 'idx >= len' }, 'x = 1 # note ends with \\\nif idx >= len: return')).toBe(true); // Python does NOT splice
+ });
+
+ it('shell `<<<` here-string is NOT a heredoc (does not swallow the following code)', () => {
+ expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'n > cap' }, 'grep foo << cap )); then :; fi')).toBe(true);
+ expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'x > y' }, 'cat < y\nEOF\n:')).toBe(false); // a real heredoc still blanks
+ });
+
+ // --- 13th-pass: Perl POD / __END__ / quote-like operators (q// qq{} s/// tr/// m// qr// qw[]) ---
+ it('Perl quote-like operators do not leak their body', () => {
+ expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'i >= size' }, 'my $s = q(check i >= size before use);')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a > b' }, '$x =~ s/note a > b here/X/g;')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a >= b' }, 'if ($x =~ m{a >= b}) { }')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a > b' }, 'my @w = qw[a > b c];')).toBe(false);
+ });
+
+ it('Perl POD (=word..=cut) and __END__/__DATA__ blocks do not leak', () => {
+ expect(guardExistsInSource({ file: 'Auth.pm', line: 3, quote: 'idx < len' }, 'package Auth;\n=head1 SAFETY\nrefused unless idx < len upstream.\n=cut\nsub access { return $arr[0]; }')).toBe(false);
+ expect(guardExistsInSource({ file: 'Q.pm', line: 3, quote: 'count > limit' }, 'sub f { 1 }\n__END__\ncount > limit is enforced\n')).toBe(false);
+ });
+
+ it('Perl quote-op lexing does not misfire on ordinary Perl (my/sub/hash), and a real guard verifies', () => {
+ expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: '$i >= $size' }, 'sub f {\n my $x = 1;\n if ($i >= $size) { return; }\n}')).toBe(true);
+ expect(guardExistsInSource({ file: 'x.pl', line: 2, quote: '$n > $cap' }, 'my $v = $h->{q};\nif ($n > $cap) { die }')).toBe(true);
+ });
+
+ // --- 14th-pass fixes ---
+ it('a single-parameter generic Type is NOT minted as a comparison guard (C++/Java/Rust/TS)', () => {
+ expect(guardExistsInSource({ file: 'a.cpp', line: 1, quote: 'vector < int' }, 'std::vector cache;\nreturn cache[i];')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.java', line: 1, quote: 'List < String' }, 'List names = new ArrayList<>();')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.rs', line: 1, quote: 'Vec < u8' }, 'let mut buf: Vec = vec![];')).toBe(false);
+ // a genuine single-`<`/`>` guard still verifies (via the operand-checked structural path)
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < len' }, 'if (idx < len) ok();')).toBe(true);
+ });
+
+ it('a sigil-dropped citation verifies against $-prefixed operands (shell/Perl/PHP)', () => {
+ expect(guardExistsInSource({ file: 'x.sh', line: 1, quote: 'n > cap' }, 'if (( $n > $cap )); then :; fi')).toBe(true);
+ expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'i >= size' }, 'if ($i >= $size) { return; }')).toBe(true);
+ });
+
+ it('PostgreSQL dollar-quoted strings ($$β¦$$ / $tag$β¦$tag$) do not leak; $N params are not confused', () => {
+ expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'n > cap' }, 'SELECT $$ if n > cap then $$;')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'idx >= len' }, 'SELECT $body$ where idx >= len $body$ AS note;')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'a > b' }, 'SELECT $1 WHERE a > b;')).toBe(true); // $1 is a param, not a dollar-quote
+ });
+
+ it('a comparison inside a JS `${β¦}` template interpolation does NOT verify (display text, not a bounds guard) and nested templates never leak', () => {
+ // A comparison in a `${β¦}` interpolation produces template display text, not a killing guard β treat
+ // it conservatively (blank), so a hallucinated guard citing it cannot false-VERIFY. Over-rejecting a
+ // (contrived) real guard hidden inside an interpolation is the SAFE direction.
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'const s = `v ${idx >= len ? 1 : 0} w`; return;')).toBe(false);
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, 't = `${`a > b`}`;')).toBe(false);
+ // interpolation reached THROUGH a nested template (a string) must not leak either
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, 'x = `${ `${ a > b }` }`;')).toBe(false);
+ // a real guard OUTSIDE any template still verifies
+ expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'const s = `hi ${name}`; if (idx >= len) return;')).toBe(true);
+ });
+
+ it('multi-parameter generics never false-VERIFY a `<` guard, and real `<` guards still verify', () => {
+ expect(guardExistsInSource({ file: 'm.ts', line: 1, quote: 'Map < K' }, 'const m: Map = new Map();')).toBe(false);
+ expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'HashMap < K' }, 'let m: HashMap = h();')).toBe(false);
+ expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'Nested < Vec' }, 'let x: Nested> = y;')).toBe(false);
+ expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'Box < T' }, 'let b: Box = z;')).toBe(false); // single-arg still rejected
+ expect(guardExistsInSource({ file: 'm.ts', line: 1, quote: 'idx < len' }, 'if (idx < len) return;')).toBe(true);
+ });
+
+ it('PHP heredoc/nowdoc bodies are blanked; `<<` left-shift is not a heredoc', () => {
+ expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'idx >= len' }, '$s = <<= len) leak\nEOT;\n$r = 0;')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'n > cap' }, "$s = <<<'EOT'\nnote n > cap here\nEOT;\n$r = 0;")).toBe(false); // nowdoc
+ expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'x > y' }, '$s = << y here\n EOT;\n$r = 0;')).toBe(false); // 7.3+ indented closer
+ expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'n > cap' }, '$y = $x << 2;\nif (n > cap) return;')).toBe(true); // `<<` = left-shift, guard survives
+ });
+
+ it('an INDENTED __END__/__DATA__ is ordinary code (only column-0 is a Perl trailer)', () => {
+ expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: 'n > cap' }, 'sub f {\n __END__\n if (n > cap) { return }\n}')).toBe(true);
+ expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: 'a > b' }, 'code();\n__END__\na > b in pod trailer')).toBe(false);
+ });
+
+ it('Perl __END__ with trailing text is blanked, and a stray leading `=` without `=cut` stays code', () => {
+ expect(guardExistsInSource({ file: 'Q.pm', line: 3, quote: 'count > limit' }, 'sub f { 1 }\n__END__ data\ncount > limit\n')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: '$n > $cap' }, 'my $r = 1\n=x\nif ($n > $cap) { die }')).toBe(true); // no =cut β not POD
+ });
+
+ it('a bare Ruby `< {
+ expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'i > n' }, 'q = < n\nSQL\nputs q')).toBe(false);
+ expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'n > cap' }, 'arr << CONST\nif n > cap then end')).toBe(true);
+ });
+
+ it('REJECTS a hallucinated guard not present in source', () => {
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (m_id >= ls.w) continue;' }, source)).toBe(false);
+ });
+
+ it('REJECTS a paraphrased comparison whose operands never co-occur on one line', () => {
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'foo >= bar' }, source)).toBe(false);
+ });
+
+ it('REJECTS on empty/short quote, empty source, or null guard', () => {
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: '' }, source)).toBe(false);
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, '')).toBe(false);
+ expect(guardExistsInSource(null, source)).toBe(false);
+ // sub-6-char quote falls through the substring path; no comparison β false
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'x;' }, source)).toBe(false);
+ });
+});
+
+describe('guardExistsInSource β hardened against hallucinated cites (HT-2 regression)', () => {
+ const source = [
+ 'void copy(size_t n) {',
+ ' if (n > cap) return; // the real killing guard',
+ ' memcpy(dst, src, n);',
+ '}',
+ ].join('\n');
+ // an OOB sink with NO bounds check at all β the exact case a hallucinating refuter targets
+ const unbounded = 'void f(int idx){ memcpy(dst, table[idx], width); }';
+
+ it('REJECTS a structural cite whose operands co-occur on a line but are never actually compared', () => {
+ // `n` and `src` both appear on the memcpy line, but `n > src` is not a comparison anywhere.
+ expect(guardExistsInSource({ file: 'a.c', line: 3, quote: 'n > src' }, source)).toBe(false);
+ });
+
+ it('REJECTS a fabricated bounds check on an unbounded sink (operands present, comparison absent)', () => {
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= width' }, unbounded)).toBe(false);
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < width' }, unbounded)).toBe(false);
+ });
+
+ it('REJECTS a comparison of two tokens that are never compared together in source', () => {
+ // source only ever compares n vs cap; {cap, dst} is not a real comparison.
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap > dst' }, source)).toBe(false);
+ });
+
+ it('REJECTS a bare common token that IS a substring but has no guard shape', () => {
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'size_t' }, source)).toBe(false);
+ // the sink line itself is not a guard, even though it is a literal substring
+ expect(guardExistsInSource({ file: 'a.c', line: 3, quote: 'memcpy(dst, src, n)' }, source)).toBe(false);
+ });
+
+ it('still verifies a genuine clamp/min guard cited exactly (no false-negative regression)', () => {
+ const clamp = 'n = std::min(n, cap);';
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'std::min(n, cap)' }, clamp)).toBe(true);
+ });
+
+ it('REJECTS a guard that exists only in a COMMENT, not real code (comment stripping)', () => {
+ const withComment = 'x = alloc();\n// if (n > cap) return; (just a note, not real code)\ny = use(x);';
+ expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, withComment)).toBe(false);
+ });
+
+ it('REJECTS a guard that only matches by STITCHING across two unrelated lines', () => {
+ const twoLines = 'if (done) break;\nprocess(data);';
+ // "break;process" is a substring of the whitespace-flattened file, but never appears on ONE line
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'break; process' }, twoLines)).toBe(false);
+ });
+
+ it('REJECTS a guard that exists only inside a STRING LITERAL, not real code', () => {
+ const inString = 'const msg = "if (n > cap) return";\nprocess(msg);';
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'if (n > cap) return' }, inString)).toBe(false);
+ });
+
+ it('does NOT false-reject a real guard sharing a line with a // inside a STRING literal', () => {
+ const withUrl = 'const u = "http://x/y"; if (n > cap) return;'; // the // is inside the string, not a comment
+ expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'n > cap' }, withUrl)).toBe(true);
+ });
+
+ it('END-TO-END: two hallucinated structural refutes no longer kill a real finding', () => {
+ const resolve = (f: string): string => (f === 'sink.c' ? unbounded : '');
+ const raw = [
+ refuted({ file: 'sink.c', line: 1, quote: 'idx >= width' }), // fabricated bound
+ refuted({ file: 'sink.c', line: 1, quote: 'idx < width' }), // fabricated bound
+ survived(),
+ ];
+ const checked = downgradeUnverifiedRefutes(raw, resolve);
+ expect(adjudicate(checked).verdict).toBe('SURVIVED');
+ expect(adjudicate(checked).refutedCount).toBe(0);
+ });
+});
+
+describe('downgradeUnverifiedRefutes β mandatory cite-check flips hallucinated refutes', () => {
+ const realSource = 'if (n > cap) return;\nmemcpy(dst, src, n);';
+ const resolve = (file: string): string => (file === 'real.c' ? realSource : '');
+
+ it('a REFUTED with a VERIFIED guard survives the check (stays REFUTED)', () => {
+ const votes = [refuted({ file: 'real.c', line: 1, quote: 'if (n > cap) return' })];
+ const out = downgradeUnverifiedRefutes(votes, resolve);
+ expect(out[0]?.verdict).toBe('REFUTED');
+ expect(out[0]?.guard_check).toBe('verified');
+ });
+
+ it('a REFUTED whose killing_guard FAILS guardExistsInSource is downgraded to SURVIVED', () => {
+ const votes = [refuted({ file: 'real.c', line: 1, quote: 'if (bogus >= nope) skip' })];
+ const out = downgradeUnverifiedRefutes(votes, resolve);
+ expect(out[0]?.verdict).toBe('SURVIVED');
+ expect(out[0]?.original_verdict).toBe('REFUTED');
+ expect(out[0]?.guard_check).toBe('unverified');
+ });
+
+ it('a REFUTED citing a MISSING file is downgraded (resolve returns empty)', () => {
+ const votes = [refuted({ file: 'ghost.c', line: 1, quote: 'if (n > cap) return' })];
+ expect(downgradeUnverifiedRefutes(votes, resolve)[0]?.verdict).toBe('SURVIVED');
+ });
+
+ it('a REFUTED with NO killing_guard cannot stand on a cite β downgraded', () => {
+ expect(downgradeUnverifiedRefutes([refuted()], resolve)[0]?.verdict).toBe('SURVIVED');
+ });
+
+ it('SURVIVED votes and nulls pass through unchanged; the check is pure (input not mutated)', () => {
+ const votes: Array = [survived(), null];
+ const out = downgradeUnverifiedRefutes(votes, resolve);
+ expect(out[0]?.verdict).toBe('SURVIVED');
+ expect(out[1]).toBeNull();
+ // purity: the original REFUTED vote object is not mutated by the downgrade
+ const original = refuted({ file: 'ghost.c', line: 1, quote: 'nope nope' });
+ downgradeUnverifiedRefutes([original], resolve);
+ expect(original.verdict).toBe('REFUTED');
+ expect(original.original_verdict).toBeUndefined();
+ });
+
+ it('END-TO-END: a minority-verified + majority-hallucinated panel does NOT refute after the cite-check', () => {
+ // 2 hallucinated REFUTED + 1 SURVIVED: raw tally would REFUTE (2 of 3).
+ const raw = [
+ refuted({ file: 'real.c', line: 1, quote: 'if (madeup > 999) die' }),
+ refuted({ file: 'real.c', line: 1, quote: 'if (alsofake < 0) bail' }),
+ survived(),
+ ];
+ expect(adjudicate(raw).verdict).toBe('REFUTED'); // before cite-check: wrongly refuted
+ const checked = downgradeUnverifiedRefutes(raw, resolve);
+ expect(adjudicate(checked).verdict).toBe('SURVIVED'); // after cite-check: real finding preserved
+ expect(adjudicate(checked).refutedCount).toBe(0);
+ });
+});
diff --git a/src/__tests__/approval.test.ts b/src/__tests__/approval.test.ts
new file mode 100644
index 0000000..b175f20
--- /dev/null
+++ b/src/__tests__/approval.test.ts
@@ -0,0 +1,120 @@
+/**
+ * Capability approval + spicy-action warning gate. Pins the safety contract:
+ * - ungated tools pass straight through; gated (intrusive/credential/dangerous) do not.
+ * - FAIL-SAFE: gated + no approver + not pre-approved β DENIED (unattended runs don't self-fire).
+ * - "approve once, then free": the interactive approver is asked ONCE per tool, then it runs free.
+ * - pre-authorization allowlist runs free (headless).
+ * - spicy actions fire a NON-BLOCKING warning every time an approved call runs; everything audited.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import {
+ ApprovalController,
+ isGatedRisk,
+ isSpicyRisk,
+ type ApprovalRequest,
+ type RiskTier,
+} from '../arsenal/approval.js';
+
+const req = (over: Partial = {}): ApprovalRequest => ({
+ tool: 'metasploit',
+ risk: 'dangerous',
+ operator: 'exploit-dev',
+ target: '10.0.0.5',
+ action: 'metasploit exploit/x β 10.0.0.5',
+ ...over,
+});
+
+describe('risk classification', () => {
+ it('gates only intrusive / credential / dangerous', () => {
+ const gated: RiskTier[] = ['intrusive', 'credential', 'dangerous'];
+ const open: RiskTier[] = ['local_read', 'passive', 'active'];
+ for (const r of gated) expect(isGatedRisk(r)).toBe(true);
+ for (const r of open) expect(isGatedRisk(r)).toBe(false);
+ expect(isGatedRisk(undefined)).toBe(false);
+ });
+ it('marks credential + dangerous as spicy (loud-warn), not intrusive', () => {
+ expect(isSpicyRisk('dangerous')).toBe(true);
+ expect(isSpicyRisk('credential')).toBe(true);
+ expect(isSpicyRisk('intrusive')).toBe(false);
+ expect(isSpicyRisk('active')).toBe(false);
+ });
+});
+
+describe('ApprovalController.gate', () => {
+ it('lets an ungated (active) tool straight through, no record', async () => {
+ const c = new ApprovalController();
+ const d = await c.gate(req({ tool: 'nmap', risk: 'active' }));
+ expect(d.allowed).toBe(true);
+ expect(c.getAudit()).toHaveLength(0);
+ });
+
+ it('FAIL-SAFE: a gated tool with no approver and no allowlist is DENIED', async () => {
+ const c = new ApprovalController(); // nothing wired
+ const d = await c.gate(req());
+ expect(d.allowed).toBe(false);
+ expect(d.reason).toMatch(/not authorized/);
+ expect(c.getAudit()[0]?.outcome).toBe('denied-no-approver');
+ });
+
+ it('pre-authorization allowlist runs free (headless), and audits', async () => {
+ const c = new ApprovalController({ preApprovedTools: ['metasploit', 'hydra'] });
+ const d = await c.gate(req());
+ expect(d.allowed).toBe(true);
+ expect(c.getAudit()[0]?.outcome).toBe('allowed-preapproved');
+ // a tool NOT on the list is still denied
+ expect((await c.gate(req({ tool: 'sqlmap', risk: 'intrusive' }))).allowed).toBe(false);
+ });
+
+ it('interactive: approve ONCE, then free β the approver is asked exactly one time', async () => {
+ const requestApproval = vi.fn(async () => true);
+ const c = new ApprovalController({ requestApproval });
+ expect((await c.gate(req())).allowed).toBe(true); // asks, approves
+ expect((await c.gate(req())).allowed).toBe(true); // free β no second ask
+ expect((await c.gate(req())).allowed).toBe(true);
+ expect(requestApproval).toHaveBeenCalledTimes(1);
+ expect(c.isApproved('metasploit')).toBe(true);
+ expect(c.getAudit().map((r) => r.outcome)).toEqual([
+ 'allowed-interactive',
+ 'allowed-preapproved',
+ 'allowed-preapproved',
+ ]);
+ });
+
+ it('interactive decline denies and does NOT approve the tool', async () => {
+ const requestApproval = vi.fn(async () => false);
+ const c = new ApprovalController({ requestApproval });
+ const d = await c.gate(req());
+ expect(d.allowed).toBe(false);
+ expect(d.reason).toMatch(/declined/);
+ expect(c.isApproved('metasploit')).toBe(false);
+ expect(c.getAudit()[0]?.outcome).toBe('denied-declined');
+ });
+
+ it('spicy actions fire a NON-BLOCKING warning every allowed run; ungated-but-approved does not', async () => {
+ const onWarning = vi.fn();
+ const c = new ApprovalController({ preApprovedTools: ['metasploit', 'sqlmap'], onWarning });
+ await c.gate(req()); // dangerous β spicy β warns
+ await c.gate(req()); // still warns on every run
+ await c.gate(req({ tool: 'sqlmap', risk: 'intrusive' })); // gated but NOT spicy β no warn
+ expect(onWarning).toHaveBeenCalledTimes(2);
+ expect(onWarning.mock.calls[0][0].tool).toBe('metasploit');
+ // the warning never blocks β all three calls were allowed
+ expect(c.getAudit().every((r) => r.outcome.startsWith('allowed'))).toBe(true);
+ });
+
+ it('audit trail is a defensive copy carrying who/what/target/outcome/spicy/time', async () => {
+ const c = new ApprovalController({ preApprovedTools: ['hydra'], now: () => 1234 });
+ await c.gate(req({ tool: 'hydra', risk: 'credential', action: 'hydra ssh 10.0.0.5' }));
+ const audit = c.getAudit();
+ expect(audit[0]).toMatchObject({
+ tool: 'hydra',
+ risk: 'credential',
+ operator: 'exploit-dev',
+ spicy: true,
+ outcome: 'allowed-preapproved',
+ at: 1234,
+ });
+ audit[0].tool = 'MUTATED';
+ expect(c.getAudit()[0].tool).toBe('hydra'); // internal trail untouched
+ });
+});
diff --git a/src/__tests__/arsenal-approval-gate.test.ts b/src/__tests__/arsenal-approval-gate.test.ts
new file mode 100644
index 0000000..d315e2e
--- /dev/null
+++ b/src/__tests__/arsenal-approval-gate.test.ts
@@ -0,0 +1,117 @@
+/**
+ * Arsenal.execute() enforces the capability-approval gate for gated tools, alongside the scope gate.
+ * A dangerous/credential/intrusive tool must not run until approved; ungated tools are untouched; the
+ * gate runs BEFORE the handler (a denied call never executes).
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { Arsenal, BUILTIN_TOOLS, EXTERNAL_TOOLS, SPICY_BUILTIN_TIERS, stampSpicyBuiltin } from '../arsenal/index.js';
+import { ApprovalController } from '../arsenal/approval.js';
+import type { CustomTool, ToolContext } from '../types/index.js';
+
+const ctx = (parameters: Record = {}): ToolContext => ({ parameters });
+
+function probe(name: string, riskTier?: CustomTool['riskTier']): CustomTool & { ran: () => boolean } {
+ let ran = false;
+ const tool: CustomTool = {
+ name, description: 'x', category: 'exploitation', riskTier, parameters: [],
+ handler: async () => { ran = true; return { success: true, output: 'DID_RUN' }; },
+ };
+ return Object.assign(tool, { ran: () => ran });
+}
+
+describe('Arsenal.execute() β approval gate', () => {
+ it('no controller wired = gating off (backward-compat): a dangerous tool still runs', async () => {
+ const ars = new Arsenal();
+ const t = probe('metasploit_module', 'dangerous');
+ ars.register(t);
+ expect((await ars.execute('metasploit_module', ctx())).success).toBe(true);
+ expect(t.ran()).toBe(true);
+ });
+
+ it('fail-safe: a gated tool is DENIED (handler never runs) when nothing approves it', async () => {
+ const ars = new Arsenal();
+ const t = probe('metasploit_module', 'dangerous');
+ ars.register(t);
+ ars.setApprovalController(new ApprovalController()); // no approver, no allowlist
+ const res = await ars.execute('metasploit_module', ctx());
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/APPROVAL REQUIRED/);
+ expect(t.ran()).toBe(false); // gate ran before the handler
+ });
+
+ it('a pre-authorized gated tool runs, and a spicy call fires the warning', async () => {
+ const ars = new Arsenal();
+ const onWarning = vi.fn();
+ const t = probe('metasploit_module', 'dangerous');
+ ars.register(t);
+ ars.setApprovalController(new ApprovalController({ preApprovedTools: ['metasploit_module'], onWarning }));
+ const res = await ars.execute('metasploit_module', ctx({ module: 'exploit/x', target: '10.0.0.5' }));
+ expect(res.success).toBe(true);
+ expect(t.ran()).toBe(true);
+ expect(onWarning).toHaveBeenCalledTimes(1);
+ expect(onWarning.mock.calls[0][0].action).toContain('metasploit_module');
+ });
+
+ it('ZERO-REGRESSION: pre-existing built-in tools stay UNGATED β only the new opt-in arsenal is gated', () => {
+ // The approval gate covers the newly-armed dangerous arsenal (metasploit/hydra + Kali+ adapters,
+ // stamped by post-ex.ts / the adapter factory). Built-in tools that ran freely before the gate
+ // existed must keep that behavior β they carry NO riskTier, so isGatedRisk() never fires on them.
+ const all = [...BUILTIN_TOOLS, ...EXTERNAL_TOOLS];
+ const tier = (name: string): string | undefined => all.find((t) => t.name === name)?.riskTier;
+ for (const n of ['sqli_scan', 'xss_scan', 'password_spray', 'hash_crack', 'dir_bruteforce']) {
+ expect(tier(n), `${n} must stay ungated (zero regression)`).toBeUndefined();
+ }
+ });
+
+ it('an ungated tool is never gated even with a locked-down controller', async () => {
+ const ars = new Arsenal();
+ const t = probe('httpx_tool', 'active'); // active = ungated
+ ars.register(t);
+ ars.setApprovalController(new ApprovalController()); // fail-safe deny for gated tools only
+ expect((await ars.execute('httpx_tool', ctx())).success).toBe(true);
+ expect(t.ran()).toBe(true);
+ });
+});
+
+describe('opt-in built-in gating (T3MP3ST_GATE_BUILTINS) β stampSpicyBuiltin', () => {
+ it('stamps ONLY the spicy built-ins with their tier, and never mutates the shared array', () => {
+ // the shared BUILTIN_TOOLS stay ungated (default posture / zero regression) β¦
+ for (const n of Object.keys(SPICY_BUILTIN_TIERS)) {
+ expect(BUILTIN_TOOLS.find((t) => t.name === n)?.riskTier, `${n} shared def stays ungated`).toBeUndefined();
+ }
+ // β¦ and stampSpicyBuiltin returns a tiered COPY for a spicy tool, the tool unchanged otherwise.
+ for (const t of BUILTIN_TOOLS) {
+ const stamped = stampSpicyBuiltin(t);
+ const wantTier = SPICY_BUILTIN_TIERS[t.name];
+ if (wantTier) {
+ expect(stamped.riskTier).toBe(wantTier);
+ expect(stamped).not.toBe(t); // fresh object β original untouched
+ expect(t.riskTier).toBeUndefined();
+ } else {
+ expect(stamped).toBe(t); // ungated tools pass through by reference
+ }
+ }
+ });
+
+ it('a stamped spicy built-in IS fenced by the same fail-safe gate (password_spray β denied unattended)', async () => {
+ const spray = BUILTIN_TOOLS.find((t) => t.name === 'password_spray');
+ expect(spray, 'password_spray must exist in the arsenal').toBeTruthy();
+ const gated = stampSpicyBuiltin(spray as CustomTool);
+ expect(gated.riskTier).toBe('credential');
+ const ars = new Arsenal();
+ ars.register(gated);
+ ars.setApprovalController(new ApprovalController()); // headless, no approver, no allowlist
+ const res = await ars.execute('password_spray', ctx({ url: 'https://target.example.com/login' }));
+ expect(res.success).toBe(false); // fail-safe deny β the opt-in gate now covers it
+ expect(res.error).toMatch(/APPROVAL REQUIRED/);
+ });
+
+ it('a stamped spicy built-in RUNS once pre-approved (approve-once / allowlist)', async () => {
+ const t = probe('password_spray', 'credential'); // as stampSpicyBuiltin would produce
+ const ars = new Arsenal();
+ ars.register(t);
+ ars.setApprovalController(new ApprovalController({ preApprovedTools: ['password_spray'] }));
+ expect((await ars.execute('password_spray', ctx({ url: 'https://target.example.com/login' }))).success).toBe(true);
+ expect(t.ran()).toBe(true);
+ });
+});
diff --git a/src/__tests__/arsenal-scope-gate.test.ts b/src/__tests__/arsenal-scope-gate.test.ts
new file mode 100644
index 0000000..32ffabc
--- /dev/null
+++ b/src/__tests__/arsenal-scope-gate.test.ts
@@ -0,0 +1,94 @@
+/**
+ * Egress scope gate β Phase-0 pack-hunt safety primitive.
+ * arsenal.execute() must refuse a networked tool call whose target is outside the authorized
+ * scope BEFORE the handler runs, so an LLM-supplied target can never reach an off-target host.
+ * Pins: scopeViolation host math, and that execute() denies out-of-scope without invoking the handler.
+ */
+import { describe, it, expect } from 'vitest';
+import { Arsenal, scopeViolation, hostFromTargetValue, type ArsenalScope } from '../arsenal/index.js';
+import type { CustomTool, ToolContext } from '../types/index.js';
+
+const scope: ArsenalScope = { allowedHosts: ['target.example.com'], allowLoopback: true, allowPrivate: true };
+const ctx = (parameters: Record, target?: string): ToolContext =>
+ ({ parameters, ...(target ? { target: { id: 't', name: 't', type: 'web_application', address: target } as never } : {}) });
+
+describe('egress scope gate', () => {
+ it('extracts a bare host from urls / user@host:port / brackets', () => {
+ expect(hostFromTargetValue('https://EVIL.com/admin?x=1')).toBe('evil.com');
+ expect(hostFromTargetValue('root@10.0.0.5:22')).toBe('10.0.0.5');
+ expect(hostFromTargetValue('scanme.example.com:443')).toBe('scanme.example.com');
+ expect(hostFromTargetValue('not a host, just text')).toBe('not a host, just text'); // still lowered; scope decides
+ expect(hostFromTargetValue(42)).toBeNull();
+ });
+
+ it('allows authorized host + its subdomains, loopback, and private ranges', () => {
+ expect(scopeViolation(scope, ctx({ url: 'https://target.example.com/x' }))).toBeNull();
+ expect(scopeViolation(scope, ctx({ url: 'https://api.target.example.com/x' }))).toBeNull(); // subdomain
+ expect(scopeViolation(scope, ctx({ host: '127.0.0.1' }))).toBeNull();
+ expect(scopeViolation(scope, ctx({ target: '10.1.2.3' }))).toBeNull();
+ expect(scopeViolation(scope, ctx({ domain: 'localhost' }))).toBeNull();
+ });
+
+ it('blocks an out-of-scope host and reports it', () => {
+ expect(scopeViolation(scope, ctx({ url: 'https://evil.com/x' }))).toBe('evil.com');
+ expect(scopeViolation(scope, ctx({ target: '8.8.8.8' }))).toBe('8.8.8.8');
+ // checks context.target.address too
+ expect(scopeViolation(scope, ctx({}, 'http://attacker.net'))).toBe('attacker.net');
+ });
+
+ it('FAILS CLOSED on a network-shaped but unparseable target (//host, file://, scheme) β no fail-open', () => {
+ // these host-normalize to '' β must be treated as OUT of scope, not "no host = allowed"
+ expect(scopeViolation(scope, ctx({ url: '//evil.com' }))).toBe('//evil.com');
+ expect(scopeViolation(scope, ctx({ target: 'file:///etc/passwd' }))).toBe('file:///etc/passwd');
+ expect(scopeViolation(scope, ctx({ rhosts: '//9.9.9.9' }))).toBe('//9.9.9.9'); // rhosts now covered too
+ });
+
+ it('ZERO-REGRESSION: a plain absolute local path is NOT blocked (local-file tools run under a scope)', () => {
+ // a path has no host and is not a network target β semgrep/trivy/slither scanning /code must pass
+ expect(scopeViolation(scope, ctx({ target: '/code/src/app.py' }))).toBeNull();
+ });
+
+ it('validates a CIDR TARGET range, not just its slash-stripped base (mask-strip escape)', () => {
+ // base 10.0.0.0 is private, but /0 would sweep the whole internet β block on the RANGE
+ expect(scopeViolation(scope, ctx({ target: '10.0.0.0/0' }))).toBe('10.0.0.0/0');
+ expect(scopeViolation(scope, ctx({ target: '192.168.0.0/8' }))).toBe('192.168.0.0/8'); // /8 base 192.168 spills out
+ // an in-block private CIDR is still allowed (zero regression for legit internal scans)
+ expect(scopeViolation(scope, ctx({ rhosts: '10.0.0.0/8' }))).toBeNull(); // exactly 10/8
+ expect(scopeViolation(scope, ctx({ target: '10.1.2.0/24' }))).toBeNull(); // within 10/8
+ // a URL path segment ending in /digits is NOT a CIDR mask
+ expect(scopeViolation(scope, ctx({ url: 'https://target.example.com/api/12' }))).toBeNull();
+ });
+
+ it('a CIDR of an exactly-allowed host beyond /32 is out of scope', () => {
+ const s2: ArsenalScope = { allowedHosts: ['203.0.113.10'], allowLoopback: false, allowPrivate: false };
+ expect(scopeViolation(s2, ctx({ target: '203.0.113.10/8' }))).toBe('203.0.113.10/8');
+ expect(scopeViolation(s2, ctx({ target: '203.0.113.10' }))).toBeNull(); // the host itself is fine
+ });
+
+ it('no scope set = enforcement off (backward-compat)', () => {
+ expect(scopeViolation(null, ctx({ url: 'https://evil.com' }))).toBeNull();
+ });
+
+ it('a tool with no target-like param is never gated', () => {
+ expect(scopeViolation(scope, ctx({ hash: 'deadbeef', text: 'hello' }))).toBeNull();
+ });
+
+ it('execute() refuses an out-of-scope call WITHOUT running the handler', async () => {
+ const ars = new Arsenal();
+ let ran = false;
+ const probe: CustomTool = {
+ name: 'probe', description: 'x', category: 'web', parameters: [],
+ handler: async () => { ran = true; return { success: true, output: 'hit' }; },
+ };
+ ars.register(probe);
+ ars.setScope(scope);
+ const denied = await ars.execute('probe', ctx({ url: 'https://evil.com' }));
+ expect(ran).toBe(false);
+ expect(denied.success).toBe(false);
+ expect(denied.error).toMatch(/SCOPE DENIED/);
+ // in-scope call runs the handler
+ const ok = await ars.execute('probe', ctx({ url: 'https://target.example.com' }));
+ expect(ran).toBe(true);
+ expect(ok.success).toBe(true);
+ });
+});
diff --git a/src/__tests__/operator-toolkits.test.ts b/src/__tests__/operator-toolkits.test.ts
new file mode 100644
index 0000000..1e9f415
--- /dev/null
+++ b/src/__tests__/operator-toolkits.test.ts
@@ -0,0 +1,52 @@
+/**
+ * Operator role toolkits β the per-operator tool allowlist (the swarm's specialization layer).
+ * Each archetype's `defaultTools` is enforced at the AgentLoop via the arsenal name-allowlist
+ * (getToolDefinitions(_, names)). Pins the four properties: real tools only, the gate exposes
+ * exactly the toolkit, each operator has BROAD coverage, the swarm collectively covers every
+ * tool, and overlap (same tool on several operators) is allowed.
+ */
+import { describe, it, expect } from 'vitest';
+import { Arsenal, BUILTIN_TOOLS, EXTERNAL_TOOLS } from '../arsenal/index.js';
+import { ARCHETYPE_PROFILES } from '../operators/index.js';
+
+describe('Operator role toolkits β specialized Β· broad Β· full-coverage Β· overlap-OK', () => {
+ const arsenal = new Arsenal();
+ arsenal.registerMany(BUILTIN_TOOLS); // same population the mission does (src/index.ts)
+ arsenal.registerMany(EXTERNAL_TOOLS);
+ const allNames = arsenal.getToolDefinitions().map(t => t.name);
+ const archetypes = Object.keys(ARCHETYPE_PROFILES) as (keyof typeof ARCHETYPE_PROFILES)[];
+
+ it('every operator toolkit contains only REAL arsenal tools (no phantoms)', () => {
+ for (const a of archetypes) {
+ const phantom = ARCHETYPE_PROFILES[a].defaultTools.filter(t => !allNames.includes(t));
+ expect(phantom, `${a} references non-existent tools`).toEqual([]);
+ }
+ });
+
+ it('the name-allowlist gate exposes EXACTLY the operator toolkit', () => {
+ for (const a of archetypes) {
+ const kit = ARCHETYPE_PROFILES[a].defaultTools;
+ const exposed = arsenal.getToolDefinitions(undefined, kit).map(t => t.name).sort();
+ expect(exposed, `${a} exposed tool set`).toEqual([...new Set(kit)].sort());
+ }
+ });
+
+ it('each operator has BROAD coverage (>= 12 tools)', () => {
+ for (const a of archetypes) {
+ expect(ARCHETYPE_PROFILES[a].defaultTools.length, `${a} toolkit size`).toBeGreaterThanOrEqual(12);
+ }
+ });
+
+ it('the swarm collectively covers EVERY arsenal tool', () => {
+ const covered = new Set(archetypes.flatMap(a => ARCHETYPE_PROFILES[a].defaultTools));
+ const uncovered = allNames.filter(n => !covered.has(n));
+ expect(uncovered, 'tools no operator can reach').toEqual([]);
+ });
+
+ it('overlap is allowed β generalist tools appear on multiple operators', () => {
+ const counts: Record = {};
+ for (const a of archetypes) for (const t of ARCHETYPE_PROFILES[a].defaultTools) counts[t] = (counts[t] || 0) + 1;
+ expect(counts['http_request'], 'http_request should be broadly shared').toBeGreaterThan(1);
+ expect(counts['technology_detect'], 'technology_detect should be broadly shared').toBeGreaterThan(1);
+ });
+});
diff --git a/src/__tests__/pack-board.test.ts b/src/__tests__/pack-board.test.ts
new file mode 100644
index 0000000..993d59d
--- /dev/null
+++ b/src/__tests__/pack-board.test.ts
@@ -0,0 +1,266 @@
+/**
+ * PackBoard β pins the load-bearing invariants of the Phase-2 shared workspace
+ * (the pack-hunt design + the red-team Β§2):
+ * - de-dup collapses a duplicate post into an ENDORSEMENT that raises smoke (HT-1)
+ * - claim() is an ATOMIC compare-and-set: two calls, same expectedVersion β exactly one grant (HT-1)
+ * - an expired lease is reclaimable (the reaper backstop)
+ * - refute() does NOT flip status by itself (HT-2)
+ * - situationReport() is BOUNDED β never dumps the raw board, even with many leads
+ */
+
+import { describe, it, expect } from 'vitest';
+import { PackBoard, dedupKey } from '../pack/board.js';
+import type { LeadInput } from '../pack/board.js';
+
+// A deterministic, hand-cranked clock so lease/liveness math is exact in tests.
+function fakeClock(start = 1_000_000) {
+ let t = start;
+ const now = () => t;
+ return { now, set: (v: number) => { t = v; }, advance: (dt: number) => { t += dt; } };
+}
+
+const leadInput = (over: Partial = {}): LeadInput => ({
+ kind: 'lead',
+ title: 'command injection in exec sink',
+ where: { file: 'src/router.js', line: 42 },
+ vulnClass: 'injection',
+ confidence: 'high',
+ provenance: 'tool',
+ cwe: 'CWE-77',
+ ...over,
+});
+
+describe('dedupKey β reuses cli-swarm formula', () => {
+ it('near lines collapse, far lines are distinct (line bucket of 15)', () => {
+ expect(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 100 }))
+ .toBe(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 103 }));
+ expect(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 100 }))
+ .not.toBe(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 580 }));
+ });
+
+ it('distinct vuln classes at the same file:line-bucket do NOT collide (H11)', () => {
+ // no CWE to disambiguate β vulnClass must keep an XSS and an SQLi a line apart distinct
+ expect(dedupKey({ file: 'app.js', line: 10, vulnClass: 'xss' }))
+ .not.toBe(dedupKey({ file: 'app.js', line: 12, vulnClass: 'sqli' }));
+ });
+
+ it('coordinate-less url leads key on the surface, not one shared empty bucket (H13)', () => {
+ expect(dedupKey({ url: 'https://x/a', vulnClass: 'ssrf' }))
+ .not.toBe(dedupKey({ url: 'https://x/b', vulnClass: 'ssrf' }));
+ });
+});
+
+describe('PackBoard β reference/evidence hardening (H12/H15/M5)', () => {
+ it('getLead returns a SNAPSHOT β mutating it cannot corrupt the CAS version/status/claim', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ const lead = board.postLead('a', leadInput());
+ const snap = board.getLead(lead.id)!;
+ snap.version = 9999; // caller pokes the returned object
+ snap.status = 'confirmed';
+ snap.claim = { by: 'x', leaseUntil: 9e15, version: 9999 };
+ const fresh = board.getLead(lead.id)!;
+ expect(fresh.version).toBe(lead.version); // internal state untouched
+ expect(fresh.status).toBe('open');
+ expect(fresh.claim).toBeUndefined();
+ });
+
+ it('a duplicate with STRONGER provenance/severity monotonically upgrades the card (never downgrades)', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ const weak = board.postLead('a', leadInput({ provenance: 'context', severity: 'low' }));
+ board.postLead('b', leadInput({ provenance: 'tool', severity: 'high' })); // same dedupKey, stronger
+ const up = board.getLead(weak.id)!;
+ expect(up.provenance).toBe('tool');
+ expect(up.severity).toBe('high');
+ board.postLead('c', leadInput({ provenance: 'none', severity: 'info' })); // weaker dup
+ const still = board.getLead(weak.id)!;
+ expect(still.provenance).toBe('tool'); // not downgraded
+ expect(still.severity).toBe('high');
+ });
+
+ it('EVENT payloads are snapshots too β a subscriber mutating a lead:claimed payload cannot corrupt the CAS', () => {
+ // the outbound accessors were hardened; the emit paths must match (7th-pass finding) or a
+ // synchronous SSE/controller subscriber could flip status / reset the version / forge a lease.
+ const board = new PackBoard({}, fakeClock().now);
+ const posted = board.postLead('a', leadInput());
+ board.on('lead:claimed', ({ lead }) => { lead.status = 'open'; lead.version = 0; delete lead.claim; });
+ board.claim(posted.id, 'agentA', posted.version);
+ const after = board.getLead(posted.id)!;
+ expect(after.status).toBe('claimed'); // not reverted to open by the subscriber
+ expect(after.version).toBe(1); // CAS token intact
+ expect(after.claim?.by).toBe('agentA'); // lease intact
+ });
+
+ it('INGEST clones `where` β mutating the caller-retained object after postLead cannot re-point the card', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ const w = { file: 'safe.js', line: 10 };
+ const snap = board.postLead('a', leadInput({ where: w }));
+ w.file = 'attacker.js';
+ (w as unknown as { url: string }).url = 'http://evil';
+ const after = board.getLead(snap.id)!;
+ expect(after.where.file).toBe('safe.js'); // internal projection immune to post-hoc mutation
+ expect((after.where as { url?: string }).url).toBeUndefined();
+ });
+});
+
+describe('postLead β de-dup collapses a duplicate into an endorsement (raises smoke)', () => {
+ it('a second post with the same dedupKey does NOT create a new lead; it endorses and raises smoke', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ const a = board.postLead('agent-a', leadInput());
+ expect(a.smoke).toBe(1);
+ expect(a.endorsements).toHaveLength(0);
+
+ // same file, a nearby line (same bucket), same CWE β same dedupKey β duplicate
+ const b = board.postLead('agent-b', leadInput({ title: 'dup from B', where: { file: 'src/router.js', line: 45 } }));
+
+ expect(board.getAllLeads()).toHaveLength(1); // no second card
+ expect(b.id).toBe(a.id); // returned the SAME lead
+ expect(b.smoke).toBe(2); // smoke rose
+ expect(b.endorsements).toHaveLength(1);
+ expect(b.endorsements[0].by).toBe('agent-b');
+ });
+
+ it('a genuinely different lead (different dedupKey) is a new card', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ board.postLead('agent-a', leadInput());
+ board.postLead('agent-a', leadInput({ where: { file: 'src/other.js', line: 9 }, cwe: 'CWE-22' }));
+ expect(board.getAllLeads()).toHaveLength(2);
+ });
+});
+
+describe('claim β a real synchronous compare-and-set (atomic)', () => {
+ it('two claims with the SAME expectedVersion β exactly one granted', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ const lead = board.postLead('poster', leadInput());
+ const v = lead.version; // both racers see the same version
+
+ const first = board.claim(lead.id, 'agent-1', v);
+ const second = board.claim(lead.id, 'agent-2', v);
+
+ const grants = [first.granted, second.granted].filter(Boolean);
+ expect(grants).toHaveLength(1); // exactly one winner
+ expect(first.granted).toBe(true); // FIFO on the single-threaded loop: first wins
+ expect(second.granted).toBe(false); // stale expectedVersion (and lease held) β denied
+
+ const held = board.getLead(lead.id);
+ expect(held?.claim?.by).toBe('agent-1');
+ expect(held?.status).toBe('claimed');
+ });
+
+ it('a stale expectedVersion is rejected and returns the current version', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ const lead = board.postLead('poster', leadInput());
+ board.endorse('x', lead.id); // bumps version
+ const res = board.claim(lead.id, 'agent-1', 0); // expected the pre-endorse version
+ expect(res.granted).toBe(false);
+ expect(res.version).toBe(board.getLead(lead.id)?.version);
+ });
+
+ it('a quorum-terminal lead (refuted/confirmed/dead) is NOT claimable β sticky refutation (HT-2)', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ const lead = board.postLead('poster', leadInput());
+ board.setLeadStatus('quorum', lead.id, 'refuted'); // the quorum killed it
+ const res = board.claim(lead.id, 'agent-1', board.getLead(lead.id)!.version);
+ expect(res.granted).toBe(false); // cannot be re-claimed back into active work
+ expect(board.getLead(lead.id)?.status).toBe('refuted'); // not resurrected to 'claimed'
+ });
+});
+
+describe('releaseExpiredClaims β an expired lease is reclaimable', () => {
+ it('reaps a lease past its leaseUntil and returns the lead to open, then another agent can claim it', () => {
+ const clock = fakeClock();
+ const board = new PackBoard({}, clock.now);
+ const lead = board.postLead('poster', leadInput());
+
+ const c = board.claim(lead.id, 'agent-1', lead.version, 1000);
+ expect(c.granted).toBe(true);
+
+ // before expiry: agent-2 cannot claim (live lease held)
+ expect(board.claim(lead.id, 'agent-2', c.version).granted).toBe(false);
+
+ clock.advance(1001); // lease window elapses
+ const released = board.releaseExpiredClaims();
+ expect(released).toContain(lead.id);
+
+ const reopened = board.getLead(lead.id);
+ expect(reopened?.claim).toBeUndefined();
+ expect(reopened?.status).toBe('open');
+
+ // now agent-2 CAN claim it with the current version
+ const retry = board.claim(lead.id, 'agent-2', reopened?.version ?? -1);
+ expect(retry.granted).toBe(true);
+ expect(board.getLead(lead.id)?.claim?.by).toBe('agent-2');
+ });
+});
+
+describe('refute β does NOT flip status by itself (HT-2)', () => {
+ it('appends a refutation vote but leaves status unchanged; only the quorum flips it', () => {
+ const board = new PackBoard({}, fakeClock().now);
+ const lead = board.postLead('poster', leadInput());
+ expect(lead.status).toBe('open');
+
+ const r = board.refute('auditor', lead.id, 'claims a startsWith guard', 'src/router.js:12');
+ expect(r?.refutations).toHaveLength(1);
+ expect(r?.refutations[0].guard).toBe('src/router.js:12');
+ expect(r?.status).toBe('open'); // STILL open β refute() is a signal, not a verdict
+
+ // even many refutations don't flip it
+ board.refute('auditor-2', lead.id, 'agrees');
+ board.refute('auditor-3', lead.id, 'agrees');
+ expect(board.getLead(lead.id)?.status).toBe('open');
+
+ // only the explicit quorum path changes status
+ board.setLeadStatus('quorum', lead.id, 'refuted');
+ expect(board.getLead(lead.id)?.status).toBe('refuted');
+ });
+});
+
+describe('situationReport β bounded, never dumps the raw board', () => {
+ it('stays under the char cap even with far more leads than the cap could hold verbatim', () => {
+ const cap = 1200;
+ const board = new PackBoard({ reportCharCap: cap, defaultMaxLeads: 8 }, fakeClock().now);
+
+ // flood the board with 500 distinct leads (distinct files β distinct dedupKeys)
+ for (let i = 0; i < 500; i++) {
+ board.postLead(`agent-${i % 7}`, leadInput({
+ title: `finding number ${i} with a deliberately long descriptive title `.repeat(3),
+ where: { file: `src/module_${i}.js`, line: (i * 31) % 400 },
+ cwe: `CWE-${i % 90}`,
+ }));
+ }
+ for (let i = 0; i < 20; i++) board.heartbeat(`agent-${i}`, 'hunting', 'x'.repeat(50));
+
+ const report = board.situationReport('agent-0', { maxLeads: 8 });
+ expect(report.length).toBeLessThanOrEqual(cap);
+ expect(report).toContain('PACK BOARD');
+ });
+
+ it('ranks unclaimed leads by smoke and excludes leads claimed by others / includes your own', () => {
+ const clock = fakeClock();
+ const board = new PackBoard({ reportCharCap: 4000 }, clock.now);
+
+ const hot = board.postLead('a', leadInput({ title: 'HOT lead', where: { file: 'src/hot.js', line: 1 }, cwe: 'CWE-1' }));
+ board.endorse('b', hot.id);
+ board.endorse('c', hot.id); // smoke = 3
+
+ const cold = board.postLead('a', leadInput({ title: 'cold lead', where: { file: 'src/cold.js', line: 1 }, cwe: 'CWE-2' }));
+
+ // a lead claimed by SOMEONE ELSE should not appear in the open-leads section
+ const other = board.postLead('a', leadInput({ title: 'owned by z', where: { file: 'src/z.js', line: 1 }, cwe: 'CWE-3' }));
+ board.claim(other.id, 'agent-z', other.version);
+
+ // a lead the reader claims β should show under YOUR CLAIMS
+ const own = board.postLead('a', leadInput({ title: 'my own work', where: { file: 'src/mine.js', line: 1 }, cwe: 'CWE-4' }));
+ board.claim(own.id, 'reader', own.version);
+
+ board.heartbeat('reader', 'hunting');
+ const report = board.situationReport('reader', { maxLeads: 8 });
+
+ expect(report).toContain('HOT lead');
+ expect(report).toContain('cold lead');
+ expect(report.indexOf('HOT lead')).toBeLessThan(report.indexOf('cold lead')); // smoke ranking
+ expect(report).not.toContain('owned by z'); // claimed by another agent β hidden from open leads
+ expect(report).toContain('YOUR CLAIMS');
+ expect(report).toContain('my own work');
+ expect(cold.smoke).toBe(1);
+ });
+});
diff --git a/src/__tests__/pack-health.test.ts b/src/__tests__/pack-health.test.ts
new file mode 100644
index 0000000..704df18
--- /dev/null
+++ b/src/__tests__/pack-health.test.ts
@@ -0,0 +1,241 @@
+/**
+ * Rate-limit / degraded-health + LLM semaphore β Phase-0 pack-hunt safety primitive (HT-3).
+ * A rate-limited slot must NEVER read as "clean / found-nothing", and a single global semaphore
+ * must cap ALL local-agent calls. Pins: isRateLimited on real quota strings, the 0.5 degraded
+ * threshold, and that the Semaphore never exceeds max concurrent holders.
+ */
+import { describe, it, expect } from 'vitest';
+import { isRateLimited, DegradedTracker, Semaphore } from '../pack/health.js';
+
+describe('isRateLimited', () => {
+ it('catches real usage-limit / 429 / quota signatures', () => {
+ // real Claude/Anthropic banner
+ expect(isRateLimited("ERROR: You've hit your usage limit. try again at 12:10 PM")).toBe(true);
+ expect(isRateLimited('Claude: you have hit your usage limit')).toBe(true);
+ expect(isRateLimited('429 Too Many Requests')).toBe(true);
+ expect(isRateLimited('HTTP 429')).toBe(true);
+ expect(isRateLimited('rate limit reached for this model')).toBe(true);
+ expect(isRateLimited('rate-limited')).toBe(true);
+ expect(isRateLimited('quota exceeded')).toBe(true);
+ expect(isRateLimited('{"error":{"code":"insufficient_quota"}}')).toBe(true);
+ expect(isRateLimited('Too Many Requests')).toBe(true);
+ });
+
+ it('catches provider-specific throttle signals the original regex missed', () => {
+ expect(isRateLimited('Error: Overloaded (overloaded_error)')).toBe(true); // Anthropic 529
+ expect(isRateLimited('HTTP 529: overloaded')).toBe(true);
+ expect(isRateLimited('TooManyRequestsException: Rate exceeded')).toBe(true); // AWS Bedrock
+ expect(isRateLimited('grpc status RESOURCE_EXHAUSTED')).toBe(true); // gRPC / Vertex
+ expect(isRateLimited('You exceeded your current quota, please check your plan')).toBe(true); // OpenAI reversed
+ expect(isRateLimited('request was throttled, retry-after 30s')).toBe(true);
+ expect(isRateLimited('503 Service Unavailable')).toBe(true);
+ });
+
+ it('does NOT false-positive on a bare integer that merely looks like a status code', () => {
+ expect(isRateLimited('see line 429 of the file')).toBe(false);
+ expect(isRateLimited('CWE-529: exposure of sensitive information')).toBe(false);
+ expect(isRateLimited('found 4290 candidate sinks')).toBe(false);
+ });
+
+ it('does NOT false-positive on benign prose that incidentally contains throttle vocabulary', () => {
+ // the signals must be the SPECIFIC provider tokens, not bare words in finding narratives
+ expect(isRateLimited('resource exhausted state in the parser')).toBe(false); // not RESOURCE_EXHAUSTED
+ expect(isRateLimited('a note about an overloaded operator')).toBe(false); // not overloaded_error
+ expect(isRateLimited('the quota management module')).toBe(false); // not "quota exceeded"
+ expect(isRateLimited('capacity planning document')).toBe(false); // not "at capacity"
+ expect(isRateLimited('opened the throttle valve')).toBe(false); // "throttle" (noun) != "throttled"/"throttling"
+ expect(isRateLimited('disk quota for the container')).toBe(false);
+ });
+
+ it('does NOT flag a rate-limit / DoS FINDING body as a throttled slot (finding vs throttle)', () => {
+ // a pack agent's OWN result describing a rate-limit/DoS vuln must read as a FINDING, not degraded
+ expect(isRateLimited('Found: rate limiting is MISSING on /login (CWE-307)')).toBe(false);
+ expect(isRateLimited('no rate-limit on password reset β brute force possible')).toBe(false);
+ expect(isRateLimited('CWE-400: uncontrolled resource consumption leads to service unavailable')).toBe(false);
+ expect(isRateLimited('recommend: slow down the attacker via a captcha')).toBe(false);
+ expect(isRateLimited('concurrent request handling bug (race)')).toBe(false);
+ expect(isRateLimited('the endpoint enforces no usage limit at all')).toBe(false);
+ // but a limit actually being HIT (a genuine throttle) is still caught
+ expect(isRateLimited('rate limit exceeded')).toBe(true);
+ expect(isRateLimited('rate-limited')).toBe(true);
+ });
+
+ it('still catches the canonical provider throttles the tightening must not lose (8th-pass regression)', () => {
+ expect(isRateLimited('{"type":"error","error":{"type":"rate_limit_error"}}')).toBe(true); // Anthropic 429 body
+ expect(isRateLimited('exceeded your per-minute rate limit')).toBe(true); // qualifier between verb and "rate limit"
+ expect(isRateLimited('usage limit reached')).toBe(true); // Claude subscription banner (verb after)
+ expect(isRateLimited("You've reached your usage limit")).toBe(true); // verb before
+ expect(isRateLimited('Message limit reached')).toBe(true);
+ // and the finding-vs-throttle discrimination still holds
+ expect(isRateLimited('the endpoint enforces no usage limit at all')).toBe(false);
+ expect(isRateLimited('rate limiting is MISSING on /login')).toBe(false);
+ });
+
+ it('catches the provider throttles the tightening MUST NOT lose (9th-pass false-negatives)', () => {
+ expect(isRateLimited('You have exceeded a secondary rate limit')).toBe(true); // GitHub/GitLab
+ expect(isRateLimited('ServiceQuotaExceededException: rate exceeded')).toBe(true); // AWS Bedrock
+ expect(isRateLimited('The engine is currently overloaded, please try again')).toBe(true); // OpenAI/Anthropic 529 prose
+ expect(isRateLimited('Overloaded')).toBe(true); // Anthropic 529 bare body
+ expect(isRateLimited('grpc error: Resource has been exhausted (e.g. check quota)')).toBe(true); // gRPC/Vertex prose
+ // still NOT a false-positive on benign code-review vocabulary
+ expect(isRateLimited('a note about an overloaded operator')).toBe(false);
+ expect(isRateLimited('resource exhausted state in the parser')).toBe(false);
+ });
+
+ it('catches the underscore 429 enum and future-tense throttle bodies (11th-pass false-negatives)', () => {
+ expect(isRateLimited('{"reason":"too_many_requests"}')).toBe(true); // HTTP 429 reason enum
+ expect(isRateLimited('TOO_MANY_REQUESTS')).toBe(true);
+ expect(isRateLimited("This request would exceed your organization's rate limit")).toBe(true); // Anthropic future-tense
+ expect(isRateLimited('you will exceed your per-minute rate limit')).toBe(true);
+ // and still not a finding body
+ expect(isRateLimited('rate limiting is MISSING on /login')).toBe(false);
+ });
+
+ it('ignores normal findings output and empty/undefined text', () => {
+ expect(isRateLimited('[{"title":"x","file":"a.py","line":5,"cwe":"CWE-22"}]')).toBe(false);
+ expect(isRateLimited('Clean β nothing exploitable in my specialty')).toBe(false);
+ expect(isRateLimited('')).toBe(false);
+ // guards against undefined leaking in despite the string type (matches cli-swarm's String(x||'') coercion)
+ expect(isRateLimited(undefined as unknown as string)).toBe(false);
+ });
+});
+
+describe('DegradedTracker β 0.5 threshold', () => {
+ it('is OK when all calls succeed', () => {
+ const t = new DegradedTracker();
+ t.record(true);
+ t.record(true);
+ t.record(true);
+ expect(t.calls).toBe(3);
+ expect(t.fails).toBe(0);
+ expect(t.verdict()).toBe('OK');
+ });
+
+ it('stays OK just below the threshold (2 of 5 failed = 0.4)', () => {
+ const t = new DegradedTracker();
+ [true, true, true, false, false].forEach((ok) => t.record(ok));
+ expect(t.failRatio()).toBeCloseTo(0.4);
+ expect(t.verdict()).toBe('OK');
+ });
+
+ it('flips to INVALID_DEGRADED exactly AT the 0.5 threshold (2 of 4 failed)', () => {
+ const t = new DegradedTracker();
+ [true, true, false, false].forEach((ok) => t.record(ok));
+ expect(t.failRatio()).toBe(0.5);
+ expect(t.verdict()).toBe('INVALID_DEGRADED');
+ });
+
+ it('is INVALID_DEGRADED when failures dominate', () => {
+ const t = new DegradedTracker();
+ [false, false, false, true].forEach((ok) => t.record(ok));
+ expect(t.verdict()).toBe('INVALID_DEGRADED');
+ });
+
+ it('a rate-limited slot poisons the verdict β never clean', () => {
+ const t = new DegradedTracker();
+ // two slots came back "successfully" but one is actually a quota banner
+ t.recordResult(true, 'Clean β no findings');
+ t.recordResult(true, "You've hit your usage limit");
+ // the quota banner is booked as a failure => 1/2 = 0.5 => degraded
+ expect(t.fails).toBe(1);
+ expect(t.verdict()).toBe('INVALID_DEGRADED');
+ });
+
+ it('an empty run (no calls) is INVALID_DEGRADED, not clean', () => {
+ const t = new DegradedTracker();
+ expect(t.verdict()).toBe('INVALID_DEGRADED');
+ expect(t.failRatio()).toBe(0);
+ });
+});
+
+describe('Semaphore', () => {
+ it('rejects a non-positive max', () => {
+ expect(() => new Semaphore(0)).toThrow(RangeError);
+ expect(() => new Semaphore(-1)).toThrow(RangeError);
+ });
+
+ it('never exceeds max concurrent holders under a burst', async () => {
+ const MAX = 3;
+ const N = 40;
+ const sem = new Semaphore(MAX);
+ let live = 0;
+ let peak = 0;
+
+ const task = async (): Promise => {
+ const release = await sem.acquire();
+ live += 1;
+ peak = Math.max(peak, live);
+ expect(live).toBeLessThanOrEqual(MAX); // invariant checked WHILE holding
+ // yield across a macrotask so overlap is real, not artificial
+ await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 5)));
+ live -= 1;
+ release();
+ };
+
+ await Promise.all(Array.from({ length: N }, task));
+ expect(peak).toBe(MAX); // saturates (N >> MAX) but never over
+ expect(live).toBe(0);
+ expect(sem.inFlight).toBe(0);
+ expect(sem.queued).toBe(0);
+ });
+
+ it('serializes fully at max=1', async () => {
+ const sem = new Semaphore(1);
+ let live = 0;
+ let peak = 0;
+ const task = async (): Promise => {
+ const release = await sem.acquire();
+ live += 1;
+ peak = Math.max(peak, live);
+ await new Promise((r) => setTimeout(r, 1));
+ live -= 1;
+ release();
+ };
+ await Promise.all([task(), task(), task(), task()]);
+ expect(peak).toBe(1);
+ });
+
+ it('serves waiters FIFO', async () => {
+ const sem = new Semaphore(1);
+ const order: number[] = [];
+ const first = await sem.acquire(); // occupy the only slot
+ // three callers queue up in order
+ const p2 = sem.acquire().then((rel) => { order.push(2); rel(); });
+ const p3 = sem.acquire().then((rel) => { order.push(3); rel(); });
+ const p4 = sem.acquire().then((rel) => { order.push(4); rel(); });
+ expect(sem.queued).toBe(3);
+ first(); // release => drains in enqueue order
+ await Promise.all([p2, p3, p4]);
+ expect(order).toEqual([2, 3, 4]);
+ });
+
+ it('double-release is a no-op (cannot inflate the pool past max)', async () => {
+ const sem = new Semaphore(1);
+ const release = await sem.acquire();
+ release();
+ release(); // second call must NOT free a phantom extra slot
+
+ let live = 0;
+ let peak = 0;
+ const task = async (): Promise => {
+ const rel = await sem.acquire();
+ live += 1;
+ peak = Math.max(peak, live);
+ await new Promise((r) => setTimeout(r, 1));
+ live -= 1;
+ rel();
+ };
+ await Promise.all([task(), task(), task()]);
+ expect(peak).toBe(1); // still fully serialized
+ });
+
+ it('run() releases even when the body throws', async () => {
+ const sem = new Semaphore(1);
+ await expect(sem.run(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
+ expect(sem.inFlight).toBe(0);
+ // slot is free again β a subsequent acquire resolves immediately
+ const release = await sem.acquire();
+ expect(sem.inFlight).toBe(1);
+ release();
+ });
+});
diff --git a/src/__tests__/post-ex.test.ts b/src/__tests__/post-ex.test.ts
new file mode 100644
index 0000000..45b6440
--- /dev/null
+++ b/src/__tests__/post-ex.test.ts
@@ -0,0 +1,152 @@
+/**
+ * Purpose-built post-ex drivers (metasploit, hydra). Pins the load-bearing safety contract:
+ * - each carries its riskTier (dangerous / credential) so the approval gate fences it.
+ * - a missing binary DEGRADES (does not throw).
+ * - the target is scope-checked AND dash-guarded before anything spawns.
+ * - metasploit refuses msf-command injection (;, newline, quotes) in module/options.
+ * - the happy path builds the expected argv; hydra caps its task rate.
+ * All deps are injected fakes β no real msfconsole/hydra is spawned.
+ */
+import { describe, it, expect } from 'vitest';
+import { createMetasploitTool, createHydraTool, buildPostExTools } from '../arsenal/post-ex.js';
+import type { AdapterToolDeps, SubprocessResult } from '../arsenal/adapter-tools.js';
+import type { ToolContext } from '../types/index.js';
+
+function makeDeps(overrides: Partial = {}): AdapterToolDeps & { spawns: Array<{ cmd: string; args: string[] }> } {
+ const spawns: Array<{ cmd: string; args: string[] }> = [];
+ return {
+ spawns,
+ isToolAvailable: async () => true,
+ runSubprocess: async (cmd, args): Promise => {
+ spawns.push({ cmd, args });
+ return { stdout: 'FAKE', stderr: '', exitCode: 0 };
+ },
+ ...overrides,
+ };
+}
+const ctx = (parameters: Record): ToolContext => ({ parameters });
+
+describe('metasploit_module driver', () => {
+ it('is a dangerous, approval-gated tool', () => {
+ expect(createMetasploitTool(makeDeps()).riskTier).toBe('dangerous');
+ });
+
+ it('degrades (no throw) when msfconsole is absent', async () => {
+ const deps = makeDeps({ isToolAvailable: async () => false });
+ const res = await createMetasploitTool(deps).handler(ctx({ module: 'auxiliary/scanner/ssh/ssh_version', target: '10.0.0.5' }));
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/not installed/);
+ expect(deps.spawns).toHaveLength(0);
+ });
+
+ it('refuses an out-of-scope RHOSTS before spawning', async () => {
+ const deps = makeDeps({ scopeOk: () => false });
+ const res = await createMetasploitTool(deps).handler(ctx({ module: 'exploit/x', target: 'evil.example.com' }));
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/SCOPE DENIED/);
+ expect(deps.spawns).toHaveLength(0);
+ });
+
+ it('refuses msf-command injection in a module/option value', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ const tool = createMetasploitTool(deps);
+ const inj = await tool.handler(ctx({ module: 'exploit/x; sessions -K', target: '10.0.0.5' }));
+ expect(inj.success).toBe(false);
+ expect(inj.error).toMatch(/invalid module|msf-command metacharacters/); // caught by the module-path validator
+ const inj2 = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { LHOST: '1.2.3.4\nexploit -j' } }));
+ expect(inj2.success).toBe(false);
+ expect(inj2.error).toMatch(/msf-command metacharacters/); // an option VALUE newline is caught by MSF_UNSAFE
+ expect(deps.spawns).toHaveLength(0);
+ });
+
+ it('refuses a target-override via options (RHOSTS/RHOST/VHOST) β no out-of-scope escape', async () => {
+ const deps = makeDeps({ scopeOk: () => true }); // the resolved target is in-scope
+ const tool = createMetasploitTool(deps);
+ const res = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { RHOSTS: '9.9.9.9' } }));
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/target override refused/);
+ expect(deps.spawns).toHaveLength(0);
+ });
+
+ it('refuses a whitespace-padded / space-bearing option key (RHOSTS-override injection escape)', async () => {
+ const deps = makeDeps({ scopeOk: () => true }); // resolved target is in-scope
+ const tool = createMetasploitTool(deps);
+ // each of these keys would inject a second `set β¦` directive into the -x string if it slipped
+ // past the exact-match target guard; the strict key validator must refuse them all.
+ for (const key of ['RHOSTS ', ' rhosts', 'RHOSTS\t', 'RHOSTS 9.9.9.9', 'AutoRunScript multi/handler']) {
+ const res = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { [key]: 'y' } }));
+ expect(res.success, `key "${key}" must be refused`).toBe(false);
+ }
+ expect(deps.spawns).toHaveLength(0);
+ // a clean option key still works
+ const ok = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { RPORT: 8080 } }));
+ expect(ok.success).toBe(true);
+ });
+
+ it('refuses a malformed // , scheme, or @ target (checked host != emitted RHOSTS)', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ // '8.8.8.8@10.0.0.5' scope-checks as 10.0.0.5 but msf would receive the raw @-value β refuse it
+ for (const t of ['//evil.com', 'file:///etc/passwd', '8.8.8.8@10.0.0.5']) {
+ const res = await createMetasploitTool(deps).handler(ctx({ module: 'exploit/x', target: t }));
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/malformed target/);
+ }
+ expect(deps.spawns).toHaveLength(0);
+ });
+
+ it('builds the resource-command -x string on the happy path', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ const res = await createMetasploitTool(deps).handler(
+ ctx({ module: 'auxiliary/scanner/ssh/ssh_login', target: '10.0.0.5', options: { RPORT: 22 } }),
+ );
+ expect(res.success).toBe(true);
+ expect(deps.spawns[0].cmd).toBe('msfconsole');
+ const cmd = deps.spawns[0].args[deps.spawns[0].args.indexOf('-x') + 1];
+ expect(cmd).toContain('use auxiliary/scanner/ssh/ssh_login');
+ expect(cmd).toContain('set RHOSTS 10.0.0.5');
+ expect(cmd).toContain('set RPORT 22');
+ expect(cmd).toContain('run');
+ });
+});
+
+describe('hydra_bruteforce driver', () => {
+ it('is a credential, approval-gated tool', () => {
+ expect(createHydraTool(makeDeps()).riskTier).toBe('credential');
+ });
+
+ it('degrades when hydra is absent', async () => {
+ const deps = makeDeps({ isToolAvailable: async () => false });
+ const res = await createHydraTool(deps).handler(ctx({ service: 'ssh', target: '10.0.0.5', user: 'root', password: 'x' }));
+ expect(res.success).toBe(false);
+ expect(res.error).toMatch(/not installed/);
+ });
+
+ it('rejects a bogus service and option-looking credentials', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ const tool = createHydraTool(deps);
+ expect((await tool.handler(ctx({ service: 'ssh; rm -rf', target: '10.0.0.5', user: 'a', password: 'b' }))).success).toBe(false);
+ expect((await tool.handler(ctx({ service: 'ssh', target: '10.0.0.5', user: '-oN', password: 'b' }))).success).toBe(false);
+ expect((await tool.handler(ctx({ service: 'ssh', target: '//evil.com', user: 'a', password: 'b' }))).success).toBe(false); // malformed target
+ expect(deps.spawns).toHaveLength(0);
+ });
+
+ it('scope-denies the target and requires user+pass', async () => {
+ expect((await createHydraTool(makeDeps({ scopeOk: () => false })).handler(ctx({ service: 'ssh', target: '8.8.8.8', user: 'a', password: 'b' }))).success).toBe(false);
+ expect((await createHydraTool(makeDeps({ scopeOk: () => true })).handler(ctx({ service: 'ssh', target: '10.0.0.5' }))).error).toMatch(/user/);
+ });
+
+ it('builds argv and caps the task rate at 16', async () => {
+ const deps = makeDeps({ scopeOk: () => true });
+ await createHydraTool(deps).handler(ctx({ service: 'ssh', target: '10.0.0.5', userlist: '/u.txt', passlist: '/p.txt', tasks: 500 }));
+ const argv = deps.spawns[0].args;
+ expect(argv).toEqual(['-L', '/u.txt', '-P', '/p.txt', '-t', '16', '-f', 'ssh://10.0.0.5']);
+ });
+});
+
+describe('buildPostExTools', () => {
+ it('returns both drivers, both approval-gated', () => {
+ const tools = buildPostExTools(makeDeps());
+ expect(tools.map((t) => t.name).sort()).toEqual(['hydra_bruteforce', 'metasploit_module']);
+ expect(tools.every((t) => t.riskTier === 'dangerous' || t.riskTier === 'credential')).toBe(true);
+ });
+});
diff --git a/src/__tests__/redact.test.ts b/src/__tests__/redact.test.ts
new file mode 100644
index 0000000..d52cbdf
--- /dev/null
+++ b/src/__tests__/redact.test.ts
@@ -0,0 +1,59 @@
+/**
+ * Secret / credential redaction (server response + audit + SSE boundary). Pins the load-bearing scrub,
+ * including the 7th-pass fix: a `scheme://user:pass@host` login URL (the shape approval audit target/
+ * action strings take for a gated credential tool) must not leak its password over the REST endpoint or
+ * the SSE feed.
+ */
+import { describe, it, expect } from 'vitest';
+import { redactString, redactSecrets } from '../redact.js';
+
+describe('redactString β URL userinfo (basic-auth) scrub', () => {
+ it('strips the password from a scheme://user:pass@host login URL (all schemes)', () => {
+ expect(redactString('https://admin:hunter2@vpn.corp/login')).toBe('https://[redacted]@vpn.corp/login');
+ expect(redactString('ssh://root:toor@10.0.0.5')).toBe('ssh://[redacted]@10.0.0.5');
+ expect(redactString('ftp://svc:S3cr3t@files.internal/x')).toBe('ftp://[redacted]@files.internal/x');
+ });
+
+ it('covers a password that itself contains @ (redacts up to the LAST @ before the host)', () => {
+ expect(redactString('https://svcacct:S3cr3t-P@ss!23@intranet.corp/login'))
+ .toBe('https://[redacted]@intranet.corp/login');
+ });
+
+ it('leaves credential-free URLs and non-authority URIs untouched', () => {
+ expect(redactString('https://example.com/path?x=1')).toBe('https://example.com/path?x=1');
+ expect(redactString('visit https://host/a@b/c for docs')).toBe('visit https://host/a@b/c for docs'); // @ after a path segment, not userinfo
+ expect(redactString('mailto:alice@example.com')).toBe('mailto:alice@example.com'); // no ://, not userinfo
+ });
+
+ it('still redacts the pre-existing secret shapes (regression)', () => {
+ expect(redactString('key AKIAIOSFODNN7EXAMPLE here')).toContain('[redacted]');
+ expect(redactString('token=abc123def456ghi')).toBe('token=[redacted]');
+ expect(redactString('Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')).toContain('Bearer [redacted]');
+ });
+});
+
+describe('redactSecrets β approval-audit record shape', () => {
+ it('scrubs a userinfo password in the audit target/action a gated credential tool would record', () => {
+ // mirrors what Arsenal.execute() records for password_spray({ url: 'https://user:pass@host/login' })
+ const record = {
+ tool: 'password_spray',
+ risk: 'credential',
+ target: 'https://admin:hunter2@vpn.corp/login',
+ action: 'password_spray (url=https://admin:hunter2@vpn.corp/login username=admin) β https://admin:hunter2@vpn.corp/login',
+ outcome: 'allowed-preapproved',
+ };
+ const out = redactSecrets(record) as typeof record;
+ expect(out.target).toBe('https://[redacted]@vpn.corp/login');
+ expect(out.action).not.toContain('hunter2');
+ expect(out.action).toContain('[redacted]');
+ });
+
+ it('redacts a key named like a secret and recurses through arrays/objects', () => {
+ const out = redactSecrets({ password: 'p', nested: [{ token: 't', url: 'https://u:pw@h/x' }] }) as {
+ password: string; nested: Array<{ token: string; url: string }>;
+ };
+ expect(out.password).toBe('[redacted]');
+ expect(out.nested[0].token).toBe('[redacted]');
+ expect(out.nested[0].url).toBe('https://[redacted]@h/x');
+ });
+});
diff --git a/src/agent/index.ts b/src/agent/index.ts
index 1dafb52..699e4e9 100755
--- a/src/agent/index.ts
+++ b/src/agent/index.ts
@@ -31,6 +31,8 @@ export interface AgentLoopOptions {
maxTokens?: number;
/** Tool categories to expose (default: all) */
toolCategories?: string[];
+ /** Explicit tool-NAME allowlist β the operator's role toolkit. Overrides toolCategories. */
+ tools?: string[];
/** Whether to include detailed tool output in context (default: true) */
verboseToolOutput?: boolean;
/** Max characters per tool result before truncation (default: 4000) */
@@ -91,6 +93,7 @@ export class AgentLoop extends EventEmitter {
maxIterations: options?.maxIterations ?? 15,
maxTokens: options?.maxTokens ?? 50000,
toolCategories: options?.toolCategories ?? [],
+ tools: options?.tools ?? [],
verboseToolOutput: options?.verboseToolOutput ?? true,
maxToolOutputLength: options?.maxToolOutputLength ?? 4000,
};
@@ -114,9 +117,11 @@ export class AgentLoop extends EventEmitter {
const seenCalls = new Map();
let noProgress = 0;
- // Get tool definitions from Arsenal
+ // Get tool definitions from Arsenal (the operator's role toolkit: name allowlist wins,
+ // then category filter, then all).
const toolDefs = this.arsenal.getToolDefinitions(
- this.options.toolCategories.length > 0 ? this.options.toolCategories : undefined
+ this.options.toolCategories.length > 0 ? this.options.toolCategories : undefined,
+ this.options.tools.length > 0 ? this.options.tools : undefined
);
// Build initial messages
@@ -308,7 +313,10 @@ export class AgentLoop extends EventEmitter {
// A bad/hallucinated tool name must NOT crash the loop. Return the callable set so the
// model self-corrects in-place instead of dying or looping on a tool that doesn't exist.
const available = this.arsenal
- .getToolDefinitions(this.options.toolCategories.length ? this.options.toolCategories : undefined)
+ .getToolDefinitions(
+ this.options.toolCategories.length ? this.options.toolCategories : undefined,
+ this.options.tools.length ? this.options.tools : undefined
+ )
.map((t) => t.name);
toolResult = {
success: false,
diff --git a/src/arsenal/adapter-tools.ts b/src/arsenal/adapter-tools.ts
new file mode 100644
index 0000000..07b08ca
--- /dev/null
+++ b/src/arsenal/adapter-tools.ts
@@ -0,0 +1,371 @@
+/**
+ * T3MP3ST Arsenal β Kali+ adapter β CustomTool factory (Phase-1)
+ *
+ * Turns the catalogued Kali+ `ToolAdapter`s (src/arsenal/catalog.ts) into real, arg-templated,
+ * scope-checked, gracefully-degrading `CustomTool`s (src/types/index.ts) that the engine's Arsenal
+ * can register and an operator can call.
+ *
+ * Design constraints (from the pack-hunt design HT-1):
+ * - Dangerous adapters stay off the callable surface: `execution === 'catalog_only'` (metasploit,
+ * hydra) and `execution === 'import_only'` (bloodhound) are NEVER minted β `adapterToCustomTool`
+ * returns `null` for them, so a keyless pack agent cannot invoke them through generic execution.
+ * - Missing binary DEGRADES, never throws: the handler returns `{ success:false, error: installHint }`
+ * exactly like the hand-written `EXTERNAL_TOOLS` (nmap_scan/nuclei_scan) do, so the model can pick
+ * another tool instead of crashing the loop.
+ * - Scope is enforced BEFORE the subprocess runs. The Arsenal already has a hard egress gate in
+ * `execute()` (scopeViolation), but that only fires when a mission has set a scope. This factory
+ * accepts an OPTIONAL `scopeOk(target)` predicate as a second, in-handler belt-and-braces check on
+ * the resolved target for THIS specific adapter; when provided and it returns false the handler
+ * returns a `SCOPE DENIED` result without spawning anything.
+ *
+ * Everything is done through INJECTED dependencies (`runSubprocess` / `isToolAvailable` from
+ * src/arsenal/index.ts, an optional `scopeOk`) so this module stays self-contained and unit-testable
+ * with fakes β it spawns no real binaries of its own and imports no server code.
+ */
+
+import type { ToolAdapter } from './catalog.js';
+import type { CustomTool, ToolContext, ToolResult } from '../types/index.js';
+
+// =============================================================================
+// INJECTED DEPENDENCIES
+// =============================================================================
+
+/** Result shape of the real `runSubprocess` in src/arsenal/index.ts. */
+export interface SubprocessResult {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+}
+
+/**
+ * The (fakeable) dependencies the factory needs. `runSubprocess` / `isToolAvailable` are the real
+ * functions exported from src/arsenal/index.ts; `scopeOk` is an optional in-handler target gate.
+ */
+export interface AdapterToolDeps {
+ /** Same signature as `runSubprocess` in src/arsenal/index.ts. */
+ runSubprocess: (
+ command: string,
+ args: string[],
+ options?: { timeout?: number; maxOutput?: number }
+ ) => Promise;
+ /** Same signature as `isToolAvailable` in src/arsenal/index.ts. */
+ isToolAvailable: (command: string) => Promise;
+ /**
+ * Optional per-target scope predicate. Receives the resolved target host/url the adapter would hit;
+ * return false to refuse the call. When omitted, the in-handler scope check is skipped (the
+ * Arsenal-level egress gate in execute() still applies at the engine boundary).
+ */
+ scopeOk?: (target: string) => boolean;
+}
+
+// =============================================================================
+// ARG TEMPLATES
+// =============================================================================
+
+/**
+ * How a given adapter's binary consumes its target + which context parameter carries that target.
+ *
+ * `build(target, params)` returns the full argv for the subprocess. `targetParam` names the
+ * ToolContext parameter the target is read from (falling back to `context.target.address`).
+ * `defaultTimeoutMs` is the per-adapter subprocess timeout (scanners get longer budgets).
+ */
+interface ArgTemplate {
+ targetParam: string;
+ defaultTimeoutMs: number;
+ build: (target: string, params: Record) => string[];
+}
+
+const str = (v: unknown): string | undefined =>
+ typeof v === 'string' && v.trim() ? v.trim() : undefined;
+
+/**
+ * Per-binary arg templates for the common command-ready adapters. Keyed by `adapter.binary` (the
+ * process name), with `adapter.id` also accepted as a fallback key so callers can template by either.
+ * Anything not listed here falls back to `DEFAULT_TEMPLATE` (pass the target as a positional arg).
+ *
+ * Templates are intentionally conservative β no intrusive flags are auto-added; risk stays where the
+ * catalog put it and the Arsenal egress gate + optional scopeOk fence the target.
+ */
+const ARG_TEMPLATES: Record = {
+ nmap: {
+ targetParam: 'target',
+ defaultTimeoutMs: 120_000,
+ // Scan flags are HARDCODED β never taken from an LLM-supplied string. A free-form `flags` param
+ // word-split into argv is arbitrary-nmap-flag injection: `-oN`/`-oX` write attacker-chosen files,
+ // `--script` runs arbitrary NSE, `-iL` reads an arbitrary file β none of which the scope gate
+ // inspects. `ports` is the only tunable, accepted ONLY if it is a pure port spec (digits, commas,
+ // dashes); anything else (spaces, letters, an injected flag) is dropped, not passed through.
+ build: (target, params) => {
+ const args = ['-sV', '-T4'];
+ const ports = str(params.ports);
+ if (ports && /^[0-9,-]+$/.test(ports)) args.push('-p', ports);
+ args.push(target);
+ return args;
+ },
+ },
+ nuclei: {
+ targetParam: 'url',
+ defaultTimeoutMs: 300_000,
+ build: (target, params) => {
+ const severity = str(params.severity) ?? 'medium,high,critical';
+ const tags = str(params.tags);
+ const args = ['-target', target, '-severity', severity, '-silent', '-jsonl'];
+ if (tags) args.push('-tags', tags);
+ return args;
+ },
+ },
+ ffuf: {
+ targetParam: 'url',
+ defaultTimeoutMs: 120_000,
+ build: (target, params) => {
+ const wordlist = str(params.wordlist) ?? '/usr/share/wordlists/dirb/common.txt';
+ const mc = str(params.mc) ?? '200,301,302,403';
+ return ['-u', target, '-w', wordlist, '-mc', mc, '-o', '/dev/stdout', '-of', 'json', '-s'];
+ },
+ },
+ sqlmap: {
+ targetParam: 'url',
+ defaultTimeoutMs: 300_000,
+ // Keep level/risk low unless the mission receipt explicitly permits intrusive testing.
+ build: (target, params) => {
+ const level = str(params.level) ?? '1';
+ const risk = str(params.risk) ?? '1';
+ return ['-u', target, '--batch', `--level=${level}`, `--risk=${risk}`];
+ },
+ },
+ gobuster: {
+ targetParam: 'url',
+ defaultTimeoutMs: 120_000,
+ build: (target, params) => {
+ const mode = str(params.mode) ?? 'dir';
+ const wordlist = str(params.wordlist) ?? '/usr/share/wordlists/dirb/common.txt';
+ return [mode, '-u', target, '-w', wordlist, '-q'];
+ },
+ },
+ nikto: {
+ targetParam: 'url',
+ defaultTimeoutMs: 300_000,
+ build: (target) => ['-h', target],
+ },
+ httpx: {
+ targetParam: 'url',
+ defaultTimeoutMs: 60_000,
+ build: (target) => ['-u', target, '-status-code', '-title', '-tech-detect', '-json', '-silent'],
+ },
+ naabu: {
+ targetParam: 'host',
+ defaultTimeoutMs: 120_000,
+ build: (target, params) => {
+ const topPorts = str(params.top_ports) ?? '100';
+ return ['-host', target, '-top-ports', topPorts, '-silent'];
+ },
+ },
+ katana: {
+ targetParam: 'url',
+ defaultTimeoutMs: 120_000,
+ build: (target) => ['-u', target, '-jsonl', '-silent'],
+ },
+ subfinder: {
+ targetParam: 'domain',
+ defaultTimeoutMs: 120_000,
+ build: (target) => ['-d', target, '-silent'],
+ },
+ dalfox: {
+ targetParam: 'url',
+ defaultTimeoutMs: 180_000,
+ build: (target) => ['url', target, '--format', 'json', '--silence'],
+ },
+ dig: {
+ targetParam: 'domain',
+ defaultTimeoutMs: 30_000,
+ build: (target, params) => {
+ const type = str(params.type);
+ const args = [target];
+ if (type) args.push(type);
+ args.push('+short');
+ return args;
+ },
+ },
+ host: {
+ targetParam: 'domain',
+ defaultTimeoutMs: 30_000,
+ build: (target) => [target],
+ },
+ whois: {
+ targetParam: 'domain',
+ defaultTimeoutMs: 30_000,
+ build: (target) => [target],
+ },
+ curl: {
+ targetParam: 'url',
+ defaultTimeoutMs: 30_000,
+ build: (target, params) => {
+ const method = str(params.method) ?? 'GET';
+ const args = ['-s', '-i', '-X', method];
+ const data = str(params.data);
+ if (data) {
+ // A `-d`/`--data` value whose first char is `@` (read a local file) or `<` (read stdin) turns
+ // curl into a local-file-disclosure / exfil primitive β `-d @/etc/passwd` POSTs that file's
+ // contents. `--data-raw` sends the body verbatim and disables @/< interpretation entirely; the
+ // explicit reject is belt-and-braces so a caller can never smuggle a file read past the gates.
+ if (/^[@<]/.test(data)) {
+ throw new Error(`curl: refusing a data value starting with '${data[0]}' (would read a local file/stdin).`);
+ }
+ args.push('--data-raw', data);
+ }
+ args.push(target);
+ return args;
+ },
+ },
+};
+
+/** Fallback for any mintable adapter without a bespoke template: pass the target as a positional arg. */
+const DEFAULT_TEMPLATE: ArgTemplate = {
+ targetParam: 'target',
+ defaultTimeoutMs: 120_000,
+ build: (target) => [target],
+};
+
+/** The parameter keys a target may arrive under, mirroring the Arsenal's SCOPE_TARGET_KEYS surface. */
+const TARGET_PARAM_KEYS = ['url', 'target', 'host', 'hostname', 'domain', 'address', 'endpoint', 'base_url'];
+
+function resolveTemplate(adapter: ToolAdapter): ArgTemplate {
+ return ARG_TEMPLATES[adapter.binary] ?? ARG_TEMPLATES[adapter.id] ?? DEFAULT_TEMPLATE;
+}
+
+/**
+ * Resolve the target string for an adapter call: the template's preferred param first, then the other
+ * common target keys, then `context.target.address`. Returns undefined if none is present.
+ */
+function resolveTarget(template: ArgTemplate, context: ToolContext): string | undefined {
+ const params = context.parameters || {};
+ const preferred = str(params[template.targetParam]);
+ if (preferred) return preferred;
+ for (const k of TARGET_PARAM_KEYS) {
+ const v = str(params[k]);
+ if (v) return v;
+ }
+ return str(context.target?.address);
+}
+
+// =============================================================================
+// FACTORY
+// =============================================================================
+
+/** True only for adapters that may be minted as callable tools (safe_command / receipt_required). */
+export function isMintable(adapter: ToolAdapter): boolean {
+ return adapter.execution === 'safe_command' || adapter.execution === 'receipt_required';
+}
+
+/**
+ * Turn one catalogued adapter into a callable `CustomTool`.
+ *
+ * Returns `null` for `execution === 'catalog_only'` (metasploit, hydra) and `execution ===
+ * 'import_only'` (bloodhound) β those are NEVER minted, so they cannot be reached through generic
+ * command execution.
+ */
+export function adapterToCustomTool(adapter: ToolAdapter, deps: AdapterToolDeps): CustomTool | null {
+ if (!isMintable(adapter)) return null; // catalog_only / import_only are never callable
+
+ const template = resolveTemplate(adapter);
+
+ const handler = async (context: ToolContext): Promise => {
+ // 1) Degrade (never throw) when the binary is absent β the model picks another tool.
+ if (!(await deps.isToolAvailable(adapter.binary))) {
+ return {
+ success: false,
+ error: `${adapter.name} (${adapter.binary}) is not installed. ${adapter.installHint}`,
+ };
+ }
+
+ // 2) Resolve the target this call would hit.
+ const target = resolveTarget(template, context);
+ if (adapter.networked && !target) {
+ return { success: false, error: `${adapter.name} requires a target (${template.targetParam}).` };
+ }
+
+ // 2b) Refuse an option-looking target: a value starting with '-' gets reparsed by the tool as a
+ // FLAG rather than a target (curl `-K`/`-o`, nikto/whois/host positional, the default
+ // positional template) β argument injection that slips past the scope gate, which only
+ // inspects hosts. Reject it at this single choke point, before any argv is built or spawned.
+ if (target && /^-/.test(target)) {
+ return {
+ success: false,
+ error: `${adapter.name}: refusing option-looking target '${target}' (leading '-' is not a valid host/URL).`,
+ };
+ }
+
+ // 3) In-handler scope belt-and-braces: refuse an out-of-scope target BEFORE spawning anything.
+ if (target && deps.scopeOk && !deps.scopeOk(target)) {
+ return {
+ success: false,
+ error: `SCOPE DENIED: target '${target}' is not in the authorized scope β ${adapter.binary} refused before execution.`,
+ };
+ }
+
+ // 4) Build argv from the per-adapter template and run the subprocess with a per-adapter timeout.
+ // A template may REFUSE a dangerous param (e.g. curl `-d @file` local-file read) by throwing β
+ // convert that into a clean failure result, never an unhandled rejection.
+ let argv: string[];
+ try {
+ argv = template.build(target ?? '', context.parameters || {});
+ } catch (err) {
+ return { success: false, error: `${adapter.name}: ${err instanceof Error ? err.message : String(err)}` };
+ }
+ const result = await deps.runSubprocess(adapter.binary, argv, { timeout: template.defaultTimeoutMs });
+
+ if (result.exitCode !== 0) {
+ return {
+ success: false,
+ error: `${adapter.binary} exited ${result.exitCode}: ${result.stderr || result.stdout || 'no output'}`,
+ output: result.stdout || undefined,
+ };
+ }
+
+ return { success: true, output: result.stdout };
+ };
+
+ return {
+ name: toolNameFor(adapter),
+ description: `${adapter.name}: ${adapter.notes} (Kali+ adapter; ${adapter.execution}, risk=${adapter.risk})`,
+ category: adapter.category,
+ // Carry the catalog risk onto the tool so Arsenal.execute()'s approval gate can see it: an
+ // intrusive/credential/dangerous adapter is inert until approved, and warns on the hottest calls.
+ riskTier: adapter.risk,
+ parameters: [
+ {
+ name: template.targetParam,
+ type: 'string',
+ description: `Target for ${adapter.name} (${adapter.evidenceKinds.join(', ') || 'evidence'}).`,
+ required: adapter.networked,
+ },
+ ],
+ handler,
+ };
+}
+
+/** The stable tool-name a given adapter mints as (used for de-dup against already-registered tools). */
+export function toolNameFor(adapter: ToolAdapter): string {
+ return `${adapter.id.replace(/[^a-z0-9]+/gi, '_')}_tool`;
+}
+
+/**
+ * Map a list of adapters to callable `CustomTool`s, skipping:
+ * - non-mintable adapters (catalog_only / import_only β dropped),
+ * - any adapter whose minted tool-name is already present in `alreadyRegistered` (so the bespoke
+ * hand-written EXTERNAL_TOOLS like nmap_scan / nuclei_scan win and we don't double-register).
+ */
+export function buildAdapterTools(
+ adapters: ToolAdapter[],
+ deps: AdapterToolDeps,
+ alreadyRegistered: ReadonlySet = new Set()
+): CustomTool[] {
+ const tools: CustomTool[] = [];
+ for (const adapter of adapters) {
+ const tool = adapterToCustomTool(adapter, deps);
+ if (!tool) continue; // catalog_only / import_only
+ if (alreadyRegistered.has(tool.name)) continue; // don't shadow an existing registration
+ tools.push(tool);
+ }
+ return tools;
+}
diff --git a/src/arsenal/approval.ts b/src/arsenal/approval.ts
new file mode 100644
index 0000000..0b8ea16
--- /dev/null
+++ b/src/arsenal/approval.ts
@@ -0,0 +1,197 @@
+/**
+ * T3MP3ST Arsenal β capability approval + spicy-action warning gate.
+ *
+ * The human-veto layer that lets an operator be trusted with live post-ex tools (metasploit, hydra,
+ * sqlmap, β¦) safely: an intrusive/credential/dangerous tool is INERT until the tool has been approved
+ * β approved once, then free for the session ("approve once, then free") β and the hottest actions
+ * still fire a loud, audited WARNING every time so you always SEE what the agent is about to do.
+ *
+ * Two ways a gated tool becomes approved:
+ * 1. INTERACTIVE β the first time an operator reaches for it, `requestApproval` asks the human;
+ * a yes approves the TOOL for the rest of the session.
+ * 2. PRE-AUTHORIZED β a headless / unattended run (benchmarks, pack hunts) is handed an allowlist
+ * up front (`preApprovedTools`); those run free, everything is audited, anything off-list denies.
+ *
+ * FAIL-SAFE: a gated tool that is neither pre-approved nor interactively approvable (no approver
+ * wired) is DENIED β an unattended run never silently fires an exploit because nobody was there to
+ * say no.
+ *
+ * SCOPE (what "gated" covers): a tool is gated iff it carries a gated `riskTier`. By default that is
+ * the OPT-IN specialist arsenal β the purpose-built post-ex drivers (metasploit=dangerous,
+ * hydra=credential) and the Kali+ adapter tools (stamped from their catalog risk). The pre-existing
+ * BUILT-IN probes (sqli_scan, password_spray, β¦) ship UNGATED for backward-compatibility β their
+ * safety boundary is the always-on egress SCOPE gate (an out-of-scope target is refused regardless of
+ * approval). An operator can extend this gate to cover the spicy built-ins too by launching with
+ * T3MP3ST_GATE_BUILTINS=1, which stamps them with a riskTier. So the fail-safe above applies to every
+ * tool that is gated; it does not, by itself, make the ungated built-ins inert.
+ *
+ * This module is PURE β no engine/server imports, no I/O. The host injects the approver + warning
+ * sink via the policy, so it is fully unit-testable with fakes. Sits behind Arsenal.execute(),
+ * alongside the egress scope gate.
+ */
+
+import type { RiskTier } from '../types/index.js';
+
+// Re-exported so callers can `import { ..., RiskTier } from './approval.js'` in one place.
+export type { RiskTier };
+
+/** Tiers that require the tool to be APPROVED (interactively or via the allowlist) before it can run. */
+const GATED_TIERS: ReadonlySet = new Set(['intrusive', 'credential', 'dangerous']);
+
+/** The hottest tiers β an approved call still fires a loud, audited, NON-BLOCKING warning each time. */
+const SPICY_TIERS: ReadonlySet = new Set(['credential', 'dangerous']);
+
+/** True for a tool whose risk requires approval before it can execute. Undefined/safe/active β false.
+ * A type guard so callers narrow `riskTier?` to a defined RiskTier after the check. */
+export function isGatedRisk(risk?: RiskTier): risk is RiskTier {
+ return risk !== undefined && GATED_TIERS.has(risk);
+}
+
+/** True for a tool whose action warrants a loud warning even when approved (exploits, cred attacks). */
+export function isSpicyRisk(risk?: RiskTier): boolean {
+ return risk !== undefined && SPICY_TIERS.has(risk);
+}
+
+/** What the agent is about to do β enough for a human to make a call, or for the audit trail. */
+export interface ApprovalRequest {
+ /** The tool being invoked (the approval unit β "approve once" approves this name). */
+ tool: string;
+ /** The tool's risk tier (drives gating + spiciness). */
+ risk: RiskTier;
+ /** The operator/agent that wants to run it, when known. */
+ operator?: string;
+ /** The resolved target the action would hit, when known (informational for the warning/audit). */
+ target?: string;
+ /** A one-line human-readable summary of the specific action (tool + key params + target). */
+ action: string;
+}
+
+/** How a gated call was resolved β recorded on every audit entry. */
+export type ApprovalOutcome =
+ | 'allowed-preapproved' // already on the allowlist or approved earlier this session
+ | 'allowed-interactive' // the operator said yes just now (and the tool is now free)
+ | 'denied-declined' // the operator said no
+ | 'denied-no-approver'; // fail-safe: gated, not approved, and no approver was wired
+
+/** One immutable audit record. The controller keeps the full trail for the engagement report. */
+export interface ApprovalRecord extends ApprovalRequest {
+ outcome: ApprovalOutcome;
+ /** Whether a spicy warning was surfaced for this call. */
+ spicy: boolean;
+ /** Epoch-ms when the decision was made. */
+ at: number;
+}
+
+export interface ApprovalPolicy {
+ /**
+ * Tools pre-authorized up front β the headless/unattended allowlist. A gated tool named here runs
+ * free (still audited, still warns on spicy actions). This is also the store the "approve once"
+ * interactive path writes into.
+ */
+ preApprovedTools?: string[];
+ /**
+ * Interactive approver, asked ONCE per gated tool the first time it is used. Return true to approve
+ * the tool for the rest of the session. If omitted, a gated tool that isn't pre-approved is DENIED
+ * (fail-safe) β the unattended/headless posture.
+ */
+ requestApproval?: (req: ApprovalRequest) => Promise;
+ /**
+ * Sink for the non-blocking spicy-action warning β surface it live (CLI banner / UI toast / event).
+ * Called on every spicy call that is allowed; the same request is also written to the audit trail.
+ */
+ onWarning?: (req: ApprovalRequest) => void;
+ /**
+ * Sink for EVERY gated decision (allowed or denied), fired as it is recorded to the audit trail.
+ * The engine wires this to an SSE event so the dashboard sees a live approval/audit feed.
+ */
+ onDecision?: (record: ApprovalRecord) => void;
+ /** Injected clock for deterministic tests. Defaults to Date.now. */
+ now?: () => number;
+}
+
+/**
+ * Holds the session's approved-tool set + the full audit trail, and adjudicates each gated call.
+ * One instance per engine/mission; wired onto the Arsenal via setApprovalController().
+ */
+export class ApprovalController {
+ private readonly approved: Set;
+ private readonly audit: ApprovalRecord[] = [];
+ private readonly now: () => number;
+
+ constructor(private readonly policy: ApprovalPolicy = {}) {
+ this.approved = new Set(policy.preApprovedTools ?? []);
+ this.now = policy.now ?? Date.now;
+ }
+
+ /** Approve a tool for the rest of the session ("approve once, then free"). */
+ approveTool(name: string): void {
+ this.approved.add(name);
+ }
+
+ /** Is this tool currently approved (via the allowlist or an earlier interactive yes)? */
+ isApproved(name: string): boolean {
+ return this.approved.has(name);
+ }
+
+ /** The names currently approved (defensive copy). */
+ approvedTools(): string[] {
+ return [...this.approved];
+ }
+
+ /** The full audit trail (defensive copy) β every gated decision + whether it warned. */
+ getAudit(): ApprovalRecord[] {
+ return this.audit.map((r) => ({ ...r }));
+ }
+
+ /**
+ * The gate. Called from Arsenal.execute() BEFORE a tool's handler runs.
+ *
+ * - ungated risk (safe/active/undefined) β allowed, nothing recorded.
+ * - gated + already approved β allowed; a spicy call fires the (non-blocking) warning. Audited.
+ * - gated + not approved + an approver is wired β ask once; yes approves the tool + allows (spicy
+ * still warns), no denies. Audited.
+ * - gated + not approved + NO approver β DENIED (fail-safe). Audited.
+ */
+ async gate(req: ApprovalRequest): Promise<{ allowed: boolean; reason: string }> {
+ if (!isGatedRisk(req.risk)) return { allowed: true, reason: 'ungated' };
+
+ const spicy = isSpicyRisk(req.risk);
+ const warnIfSpicy = (): void => {
+ if (spicy && this.policy.onWarning) this.policy.onWarning(req);
+ };
+ const record = (outcome: ApprovalOutcome): void => {
+ const rec: ApprovalRecord = { ...req, outcome, spicy, at: this.now() };
+ this.audit.push(rec);
+ this.policy.onDecision?.(rec);
+ };
+
+ // Already approved β the allowlist, or an earlier "approve once".
+ if (this.approved.has(req.tool)) {
+ warnIfSpicy();
+ record('allowed-preapproved');
+ return { allowed: true, reason: `approved (${req.risk})` };
+ }
+
+ // Not yet approved β ask the human once, if an approver is wired.
+ if (this.policy.requestApproval) {
+ const ok = await this.policy.requestApproval(req);
+ if (ok) {
+ this.approveTool(req.tool); // approve once, then free
+ warnIfSpicy();
+ record('allowed-interactive');
+ return { allowed: true, reason: `operator approved ${req.tool}` };
+ }
+ record('denied-declined');
+ return { allowed: false, reason: `${req.tool} (${req.risk}) was declined by the operator` };
+ }
+
+ // Fail-safe: gated, not approved, no way to ask.
+ record('denied-no-approver');
+ return {
+ allowed: false,
+ reason:
+ `${req.tool} is a ${req.risk} tool and is not authorized. Approve it up front ` +
+ `(pre-authorization allowlist) or via an interactive approver before use.`,
+ };
+ }
+}
diff --git a/src/arsenal/index.ts b/src/arsenal/index.ts
index 252e9ce..e885c8c 100755
--- a/src/arsenal/index.ts
+++ b/src/arsenal/index.ts
@@ -11,6 +11,7 @@ import { promisify } from 'util';
import * as net from 'net';
import * as dns from 'dns';
import * as tls from 'tls';
+import { ApprovalController, isGatedRisk, type ApprovalRequest } from './approval.js';
const execFileAsync = promisify(execFile);
import type {
@@ -19,6 +20,7 @@ import type {
ToolResult,
Target,
LLMToolDefinition,
+ RiskTier,
} from '../types/index.js';
const dnsResolve = promisify(dns.resolve);
@@ -82,8 +84,121 @@ export interface ToolExecution {
// ARSENAL
// =============================================================================
+// ββ Egress scope gate (Phase-0 pack-hunt safety primitive) ββββββββββββββββββββββββββββββββββ
+// A HARD allowlist enforced inside Arsenal.execute() BEFORE any handler runs, so an LLM-supplied
+// tool target can never reach an out-of-scope host. When no scope is set (library/test mode)
+// enforcement is off; the server/mission sets it from the authorized targets so live runs are gated.
+// This closes the "a keyless pack operator fires nuclei/nmap/sqlmap at an off-target host" hole:
+// the only prior scope check was at the HTTP boundary, keyed to the mission target β the per-tool
+// target the model actually supplies was never checked.
+export interface ArsenalScope {
+ /** exact hosts / registrable domains explicitly authorized (subdomains allowed) */
+ allowedHosts: string[];
+ /** allow 127.0.0.0/8, localhost, ::1 */
+ allowLoopback: boolean;
+ /** allow RFC-1918 / link-local / ULA lab ranges */
+ allowPrivate: boolean;
+}
+
+/** The parameter keys a networked tool reads its target from (dns/web/vuln handlers). */
+const SCOPE_TARGET_KEYS = ['url', 'target', 'host', 'hostname', 'domain', 'address', 'ip', 'endpoint', 'base_url', 'rhosts', 'rhost'];
+
+/** Normalize a target-ish value to a bare lowercase host (strip scheme/user/port/path). null if not host-like. */
+export function hostFromTargetValue(v: unknown): string | null {
+ if (typeof v !== 'string' || !v.trim()) return null;
+ let s = v.trim();
+ try { if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = new URL(s).hostname; } catch { /* treat as bare host */ }
+ s = s.replace(/^[^@/]*@/, '').replace(/\/.*$/, '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
+ return s.toLowerCase() || null;
+}
+
+function isLoopbackTargetHost(h: string): boolean {
+ return h === 'localhost' || h === '::1' || /^127(?:\.\d{1,3}){3}$/.test(h);
+}
+function isPrivateTargetHost(h: string): boolean {
+ return /^10(?:\.\d{1,3}){3}$/.test(h)
+ || /^192\.168(?:\.\d{1,3}){2}$/.test(h)
+ || /^172\.(1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}$/.test(h)
+ || /^169\.254(?:\.\d{1,3}){2}$/.test(h) // link-local
+ || /^(fc|fd)[0-9a-f]{2}:/i.test(h); // IPv6 ULA
+}
+
+/** The narrowest CIDR prefix that keeps a private/loopback host's whole block in scope β a wider
+ * (smaller-number) mask would spill outside it. Used to scope-validate a CIDR TARGET's range. */
+function privateBlockMinMask(h: string): number {
+ if (/^192\.168\./.test(h) || /^169\.254\./.test(h)) return 16;
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return 12;
+ return 8; // 10/8, 127/8, and (conservatively) IPv6 ULA
+}
+
+/** null = in scope (or no host to check); otherwise the out-of-scope host/range that must be blocked. */
+export function scopeViolation(scope: ArsenalScope | null, context: ToolContext): string | null {
+ if (!scope) return null; // enforcement off until configured
+ // FAIL CLOSED on a value that LOOKS like a network target (scheme-relative "//host" or a
+ // "scheme://β¦") but yields no parseable host β that's the bypass ("//evil.com" /
+ // "file:///etc/passwd" host-normalize to an empty string and would otherwise slip past the gate).
+ // A plain local path ("/code/src") is NOT a network target and has no host, so it's skipped β local
+ // file-analysis tools (semgrep/trivy/slither) keep working. (adversarial-review C1)
+ const bypassLooking = (v: string): boolean => v.startsWith('//') || v.includes('://');
+ const candidates: Array<{ h: string; v: string }> = [];
+ const raws: unknown[] = [context.target?.address, ...SCOPE_TARGET_KEYS.map((k) => (context.parameters || {})[k])];
+ for (const raw of raws) {
+ if (typeof raw !== 'string' || !raw.trim()) continue;
+ const v = raw.trim();
+ const h = hostFromTargetValue(v);
+ if (!h) {
+ if (bypassLooking(v)) return v; // network-target-shaped but unparseable β block
+ continue; // plain path / non-network value β not a scope target
+ }
+ candidates.push({ h, v });
+ }
+ const allow = scope.allowedHosts.map(a => a.toLowerCase());
+ for (const { h, v } of candidates) {
+ // How is the BASE host admitted β and what CIDR prefix keeps the WHOLE range inside that block?
+ // hostFromTargetValue strips the mask, so "10.0.0.0/0" (base 10.0.0.0 private) would otherwise
+ // sweep the entire internet past a scope authorizing only that base. Validate the range, not the
+ // base. (adversarial-review CIDR mask-strip escape)
+ let minMask: number;
+ if (scope.allowLoopback && isLoopbackTargetHost(h)) minMask = 8; // 127/8, ::1
+ else if (scope.allowPrivate && isPrivateTargetHost(h)) minMask = privateBlockMinMask(h);
+ else if (allow.some(a => a === h || h.endsWith('.' + a))) minMask = 32; // exact-allowed host β only /32
+ else return h; // base host out of scope
+ // treat a trailing "/N" as a CIDR mask ONLY on a bare host/ip (no scheme) β not a URL path segment.
+ const cidr = !v.includes('://') && v.match(/^[^/]+\/(\d{1,3})$/);
+ if (cidr && parseInt(cidr[1], 10) < minMask) return v; // CIDR range extends beyond the authorized block
+ }
+ return null;
+}
+
+/** Best-effort target for an approval request/warning β the first target-like param, else the
+ * context target address. Informational only (the scope gate does the real host math). */
+function approvalTargetOf(context: ToolContext): string | undefined {
+ const params = context.parameters || {};
+ for (const k of SCOPE_TARGET_KEYS) {
+ const v = params[k];
+ if (typeof v === 'string' && v.trim()) return v.trim();
+ }
+ return context.target?.address;
+}
+
+/** A one-line human-readable summary of a tool call β tool + a few scalar params + target β for the
+ * approval warning and the audit trail. Object/array params are omitted to keep it short + safe. */
+function describeToolAction(toolName: string, context: ToolContext): string {
+ const summary = Object.entries(context.parameters || {})
+ .filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object')
+ .slice(0, 4)
+ .map(([k, v]) => `${k}=${String(v).slice(0, 48)}`)
+ .join(' ');
+ const target = approvalTargetOf(context);
+ return `${toolName}${summary ? ` (${summary})` : ''}${target ? ` β ${target}` : ''}`;
+}
+
export class Arsenal extends EventEmitter {
private tools: Map = new Map();
+ /** Authorized egress scope; null = enforcement off (set by the server/mission for live runs). */
+ private scope: ArsenalScope | null = null;
+ /** Capability approval gate; null = gating off (backward-compat). The engine wires one for live runs. */
+ private approval: ApprovalController | null = null;
private executions: ToolExecution[] = [];
/**
@@ -124,6 +239,14 @@ export class Arsenal extends EventEmitter {
return this.getAllTools().filter(t => t.category === category);
}
+ /** Set (or clear with null) the authorized egress scope enforced in execute(). */
+ setScope(scope: ArsenalScope | null): void { this.scope = scope; }
+ getScope(): ArsenalScope | null { return this.scope; }
+
+ /** Set (or clear) the capability-approval gate enforced in execute() for intrusive/dangerous tools. */
+ setApprovalController(approval: ApprovalController | null): void { this.approval = approval; }
+ getApprovalController(): ApprovalController | null { return this.approval; }
+
/**
* Execute a tool
*/
@@ -136,6 +259,38 @@ export class Arsenal extends EventEmitter {
throw new Error(`Tool "${toolName}" not found`);
}
+ // Egress scope gate: deny out-of-scope network targets BEFORE the handler runs. A tool call
+ // never reaches a host outside the authorized scope, regardless of what the model supplied.
+ const blockedHost = scopeViolation(this.scope, context);
+ if (blockedHost) {
+ const denied: ToolResult = {
+ success: false,
+ error: `SCOPE DENIED: target '${blockedHost}' is not in the authorized scope β ${toolName} refused before execution. Only authorized / loopback / lab targets are permitted.`,
+ };
+ this.emit('tool:error', { tool, error: new Error(denied.error) });
+ return denied;
+ }
+
+ // Capability approval + spicy-action warning gate: an intrusive/credential/dangerous tool is inert
+ // until it has been approved (interactively "approve once, then free", or via the headless
+ // pre-authorization allowlist); the hottest actions also fire a loud, audited, non-blocking
+ // warning. Safe/active tools pass straight through. No controller wired = gating off (backward-compat).
+ if (this.approval && isGatedRisk(tool.riskTier)) {
+ const request: ApprovalRequest = {
+ tool: toolName,
+ risk: tool.riskTier,
+ operator: context.operator,
+ target: approvalTargetOf(context),
+ action: describeToolAction(toolName, context),
+ };
+ const decision = await this.approval.gate(request);
+ if (!decision.allowed) {
+ const denied: ToolResult = { success: false, error: `APPROVAL REQUIRED: ${decision.reason}` };
+ this.emit('tool:error', { tool, error: new Error(denied.error) });
+ return denied;
+ }
+ }
+
const execution: ToolExecution = {
id: randomUUID(),
toolName,
@@ -189,9 +344,13 @@ export class Arsenal extends EventEmitter {
/**
* Convert registered tools to LLM tool definitions for function calling
*/
- getToolDefinitions(categories?: string[]): LLMToolDefinition[] {
+ getToolDefinitions(categories?: string[], names?: string[]): LLMToolDefinition[] {
let tools = this.getAllTools();
- if (categories?.length) {
+ // A per-operator NAME allowlist (the archetype's role toolkit) is the precise gate and
+ // takes precedence over the coarse category filter; fall back to categories, then to all.
+ if (names?.length) {
+ tools = tools.filter(t => names.includes(t.name));
+ } else if (categories?.length) {
tools = tools.filter(t => categories.includes(t.category));
}
return tools.map(tool => {
@@ -272,6 +431,29 @@ export function createToolContext(
// BUILT-IN TOOLS
// =============================================================================
+/**
+ * The built-in probes that are genuinely "spicy" β live credential attacks and active injection
+ * testing. They ship UNGATED (no riskTier below) so every prior run and the headline benchmark keep
+ * firing them freely β their safety boundary is the always-on egress scope gate. An operator who wants
+ * the approval fail-safe to blanket these too opts in with T3MP3ST_GATE_BUILTINS=1, which applies these
+ * tiers via stampSpicyBuiltin() at registration. Kept here, next to BUILTIN_TOOLS, as the single source
+ * of truth for which built-ins are spicy.
+ */
+export const SPICY_BUILTIN_TIERS: Readonly> = {
+ password_spray: 'credential',
+ hash_crack: 'credential',
+ sqli_scan: 'intrusive',
+ xss_scan: 'intrusive',
+};
+
+/** Copy of `tool` stamped with a gated riskTier iff it is a spicy built-in that isn't already tiered;
+ * otherwise the tool unchanged. Never mutates the shared BUILTIN_TOOLS array β only clones when it
+ * stamps β so the default (ungated) registration and this opt-in path can coexist. */
+export function stampSpicyBuiltin(tool: CustomTool): CustomTool {
+ const tier = SPICY_BUILTIN_TIERS[tool.name];
+ return tier && tool.riskTier === undefined ? { ...tool, riskTier: tier } : tool;
+}
+
export const BUILTIN_TOOLS: CustomTool[] = [
// =============================================================================
// RECONNAISSANCE TOOLS
diff --git a/src/arsenal/post-ex.ts b/src/arsenal/post-ex.ts
new file mode 100644
index 0000000..4027946
--- /dev/null
+++ b/src/arsenal/post-ex.ts
@@ -0,0 +1,232 @@
+/**
+ * T3MP3ST Arsenal β purpose-built post-exploitation drivers (metasploit, hydra).
+ *
+ * The catalog withholds these from the GENERIC adapter factory (`execution: catalog_only`) because
+ * the generic "one-shot CLI + a target arg" shape can't drive them: msfconsole is a command REPL, not
+ * `msfconsole `. So they get real, hand-written drivers here instead β structured module/option
+ * invocation with parseable output β and each carries its `riskTier` so Arsenal.execute()'s approval
+ * gate fences it: a `dangerous`/`credential` tool is inert until the operator has approved it, and the
+ * hottest actions fire a loud audited warning.
+ *
+ * SAFETY (these run live exploitation / real auth traffic):
+ * - the target is scope-checked (the injected `scopeOk`) AND rejected if it looks like a flag.
+ * - metasploit values are sanitized against msf-command injection into the `-x` command string
+ * (a `;` or newline in a module/option would inject extra console commands).
+ * - a missing binary DEGRADES (returns an error result) instead of throwing.
+ *
+ * Dependencies are INJECTED (same `AdapterToolDeps` the adapter factory uses) so this module spawns no
+ * real binaries of its own and is unit-testable with fakes.
+ */
+
+import type { AdapterToolDeps } from './adapter-tools.js';
+import type { CustomTool, ToolContext, ToolResult } from '../types/index.js';
+
+const str = (v: unknown): string | undefined =>
+ typeof v === 'string' && v.trim() ? v.trim() : undefined;
+
+/** Resolve the target for a post-ex call: an explicit `target`/`rhosts` param, else the context target. */
+function resolveTarget(context: ToolContext): string | undefined {
+ const p = context.parameters || {};
+ return str(p.target) ?? str(p.rhosts) ?? str(context.target?.address);
+}
+
+/** Reject an option-looking value (leading '-') a tool would reparse as its own flag. */
+function optionLooking(v: string): boolean {
+ return /^-/.test(v);
+}
+
+/** A target a tool would misparse or that defeats the scope gate: leading '-' (a flag), leading '/'
+ * or a scheme `x://` (host-normalizes to empty β fail-open), or an `@` (user@host β the scope gate
+ * checks the host AFTER the @, but msf/hydra would receive the raw value, a checkedβ emitted mismatch).
+ * Bare hosts / IPs / CIDRs (10.0.0.0/24) pass β CIDR RANGE is validated by the scope gate. */
+function unsafeTarget(v: string): boolean {
+ return /^[-/]/.test(v) || /:\/\//.test(v) || v.includes('@');
+}
+
+/** msf option keys that set the TARGET host β must not be settable via `options` (that would override
+ * the scope-checked `target`/RHOSTS with an unauthorized host). The target comes only from `target`. */
+const MSF_TARGET_OPT_KEYS: ReadonlySet = new Set(['rhosts', 'rhost', 'vhost']);
+
+// =============================================================================
+// METASPLOIT β resource-command driver via `msfconsole -q -x ""`
+// =============================================================================
+
+// Chars that would break out of an msf command in the `-x` string and inject extra console commands
+// (`;` = command separator, CR/LF = new line, quotes/backtick = string breakout). A value containing
+// any of these is refused rather than sanitized-and-run β fail closed.
+const MSF_UNSAFE = /[;\r\n"'`]/;
+
+/** Build a Metasploit driver tool: `use ; set RHOSTS ; [set β¦]; run; exit`. */
+export function createMetasploitTool(deps: AdapterToolDeps): CustomTool {
+ const handler = async (context: ToolContext): Promise => {
+ if (!(await deps.isToolAvailable('msfconsole'))) {
+ return { success: false, error: 'metasploit (msfconsole) is not installed. Install the Metasploit Framework.' };
+ }
+ const p = context.parameters || {};
+ const module = str(p.module);
+ if (!module) return { success: false, error: 'metasploit requires a `module` (e.g. exploit/β¦, auxiliary/β¦).' };
+ // module must be a clean msf module PATH β no spaces or metachars that could add stray tokens to
+ // the `use` command (defense-in-depth on top of MSF_UNSAFE, which already blocks ; and newlines).
+ if (!/^[A-Za-z0-9/_.-]+$/.test(module)) {
+ return { success: false, error: `metasploit: invalid module '${module}' (module paths are alphanumeric plus / _ . -).` };
+ }
+
+ const target = resolveTarget(context);
+ if (!target) return { success: false, error: 'metasploit requires a `target` (RHOSTS).' };
+ if (unsafeTarget(target)) return { success: false, error: `metasploit: refusing malformed target '${target}' (must be a bare host / IP / CIDR).` };
+ if (deps.scopeOk && !deps.scopeOk(target)) {
+ return { success: false, error: `SCOPE DENIED: RHOSTS '${target}' is not in the authorized scope β metasploit refused before execution.` };
+ }
+
+ const payload = str(p.payload);
+ if (payload && !/^[A-Za-z0-9/_.-]+$/.test(payload)) {
+ return { success: false, error: `metasploit: invalid payload '${payload}' (payload names are alphanumeric plus / _ . -).` };
+ }
+ const options =
+ p.options && typeof p.options === 'object' && !Array.isArray(p.options)
+ ? (p.options as Record)
+ : {};
+
+ // Every option key must be a clean msf option name [A-Za-z0-9_]+. This rejects whitespace-padded or
+ // space-bearing keys ("RHOSTS ", "RHOSTS 9.9.9.9", "AutoRunScript multi/handler") that would slip
+ // past the target-override guard below and inject a SECOND `set` directive into the -x string β a
+ // scope escape to an arbitrary host (msf `set RHOSTS` is last-write-wins). Then refuse any option
+ // that re-sets the target host: the target comes only via the scope-checked `target` param.
+ for (const k of Object.keys(options)) {
+ if (!/^[A-Za-z0-9_]+$/.test(k)) {
+ return { success: false, error: `metasploit: invalid option key '${k}' (msf option names must be alphanumeric/underscore).` };
+ }
+ if (MSF_TARGET_OPT_KEYS.has(k.toLowerCase())) {
+ return { success: false, error: `metasploit: set the target via the \`target\` param, not the \`${k}\` option (target override refused).` };
+ }
+ }
+
+ // Sanitize every value that lands in the -x command string against msf-command injection.
+ const values: Array<[string, string]> = [['module', module], ['RHOSTS', target]];
+ if (payload) values.push(['PAYLOAD', payload]);
+ for (const [k, v] of Object.entries(options)) values.push([k, String(v)]);
+ for (const [k, v] of values) {
+ if (MSF_UNSAFE.test(k) || MSF_UNSAFE.test(v)) {
+ return { success: false, error: `metasploit: refusing a value with msf-command metacharacters (;, newline, quotes) in '${k}'.` };
+ }
+ }
+
+ const commands = [
+ `use ${module}`,
+ `set RHOSTS ${target}`,
+ ...(payload ? [`set PAYLOAD ${payload}`] : []),
+ ...Object.entries(options).map(([k, v]) => `set ${k} ${String(v)}`),
+ 'run',
+ 'exit',
+ ].join('; ');
+
+ const result = await deps.runSubprocess('msfconsole', ['-q', '-x', commands], { timeout: 600_000 });
+ if (result.exitCode !== 0) {
+ return {
+ success: false,
+ error: `msfconsole exited ${result.exitCode}: ${result.stderr || result.stdout || 'no output'}`,
+ output: result.stdout || undefined,
+ };
+ }
+ return { success: true, output: result.stdout };
+ };
+
+ return {
+ name: 'metasploit_module',
+ description:
+ 'Run a Metasploit module (exploit/auxiliary/post) against an in-scope target via msfconsole. ' +
+ 'Params: module, target (RHOSTS), payload?, options{}. DANGEROUS β approval-gated live exploitation.',
+ category: 'exploitation',
+ riskTier: 'dangerous',
+ parameters: [
+ { name: 'module', type: 'string', description: 'Module path (e.g. exploit/linux/http/β¦, auxiliary/scanner/β¦).', required: true },
+ { name: 'target', type: 'string', description: 'RHOSTS β the in-scope target host/IP.', required: true },
+ { name: 'payload', type: 'string', description: 'Optional payload (e.g. linux/x64/meterpreter/reverse_tcp).', required: false },
+ { name: 'options', type: 'object', description: 'Optional module options as {KEY: value} (e.g. {RPORT: 8080, LHOST: 10.0.0.2}).', required: false },
+ ],
+ handler,
+ };
+}
+
+// =============================================================================
+// HYDRA β online credential brute-force
+// =============================================================================
+
+/** Build a THC-Hydra driver tool: `hydra [-l user|-L userlist] [-p pass|-P passlist] -t N -f service://target`. */
+export function createHydraTool(deps: AdapterToolDeps): CustomTool {
+ const handler = async (context: ToolContext): Promise => {
+ if (!(await deps.isToolAvailable('hydra'))) {
+ return { success: false, error: 'hydra is not installed. Install THC-Hydra.' };
+ }
+ const p = context.parameters || {};
+ const service = str(p.service);
+ const target = resolveTarget(context);
+ if (!service) return { success: false, error: 'hydra requires a `service` (ssh, ftp, http-get, smb, β¦).' };
+ if (!target) return { success: false, error: 'hydra requires a `target`.' };
+ if (!/^[a-z0-9][a-z0-9._+-]*$/i.test(service)) {
+ return { success: false, error: `hydra: invalid service '${service}' (expected a hydra module name).` };
+ }
+
+ const user = str(p.user);
+ const userlist = str(p.userlist);
+ const password = str(p.password);
+ const passlist = str(p.passlist);
+ if (unsafeTarget(target)) return { success: false, error: `hydra: refusing malformed target '${target}' (must be a bare host / IP).` };
+ // Refuse option-looking credential values that hydra would reparse as its own flags.
+ for (const [label, val] of [['user', user], ['password', password], ['userlist', userlist], ['passlist', passlist]] as const) {
+ if (val && optionLooking(val)) return { success: false, error: `hydra: refusing option-looking ${label} '${val}'.` };
+ }
+ if (deps.scopeOk && !deps.scopeOk(target)) {
+ return { success: false, error: `SCOPE DENIED: target '${target}' is not in the authorized scope β hydra refused before execution.` };
+ }
+
+ const argv: string[] = [];
+ if (userlist) argv.push('-L', userlist);
+ else if (user) argv.push('-l', user);
+ else return { success: false, error: 'hydra requires a `user` or a `userlist`.' };
+ if (passlist) argv.push('-P', passlist);
+ else if (password) argv.push('-p', password);
+ else return { success: false, error: 'hydra requires a `password` or a `passlist`.' };
+
+ const tasks = Math.min(Math.max(1, Math.floor(Number(p.tasks) || 4)), 16); // cap to avoid lockouts/DoS
+ argv.push('-t', String(tasks), '-f', `${service}://${target}`);
+
+ const result = await deps.runSubprocess('hydra', argv, { timeout: 600_000 });
+ // hydra found-credential lines look like: "[22][ssh] host: X login: Y password: Z"
+ const hits = [...result.stdout.matchAll(/login:\s*(\S+)\s+password:\s*(\S+)/gi)].map((m) => `${m[1]}:${m[2]}`);
+ if (hits.length) {
+ return { success: true, output: `${result.stdout}\n\nCREDENTIALS FOUND: ${hits.join(', ')}` };
+ }
+ if (result.exitCode !== 0 && !result.stdout) {
+ return { success: false, error: `hydra exited ${result.exitCode}: ${result.stderr || 'no output'}` };
+ }
+ return { success: true, output: result.stdout || 'hydra completed; no credentials found.' };
+ };
+
+ return {
+ name: 'hydra_bruteforce',
+ description:
+ 'Online credential brute-force with THC-Hydra against an in-scope service. ' +
+ 'Params: service, target, user|userlist, password|passlist, tasks?. CREDENTIAL attack β approval-gated real auth traffic.',
+ category: 'credential-access',
+ riskTier: 'credential',
+ parameters: [
+ { name: 'service', type: 'string', description: 'Hydra service module (ssh, ftp, http-get, http-post-form, smb, β¦).', required: true },
+ { name: 'target', type: 'string', description: 'In-scope target host/IP.', required: true },
+ { name: 'user', type: 'string', description: 'Single username (or use userlist).', required: false },
+ { name: 'userlist', type: 'string', description: 'Path to a username wordlist.', required: false },
+ { name: 'password', type: 'string', description: 'Single password (or use passlist).', required: false },
+ { name: 'passlist', type: 'string', description: 'Path to a password wordlist.', required: false },
+ { name: 'tasks', type: 'number', description: 'Parallel tasks (1-16, default 4) β keep low to avoid lockouts.', required: false },
+ ],
+ handler,
+ };
+}
+
+/** All purpose-built post-ex driver tools (metasploit, hydra). Registered by the engine when the full
+ * arsenal is armed; each is approval-gated by its riskTier. Bloodhound (collectorβgraph pipeline) is
+ * deliberately deferred β it's an AD-analysis tool, not a target-firing CLI, and least relevant to
+ * bug-bounty/CTF work. */
+export function buildPostExTools(deps: AdapterToolDeps): CustomTool[] {
+ return [createMetasploitTool(deps), createHydraTool(deps)];
+}
diff --git a/src/index.ts b/src/index.ts
index c9fe8b6..09462c7 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -221,7 +221,20 @@ import { OperatorCell, OperatorAgent, ARCHETYPE_PROFILES } from './operators/ind
import { MissionControl, TaskQueue } from './mission/index.js';
import { TargetEnvironment } from './target/index.js';
import { EvidenceVault } from './evidence/index.js';
-import { Arsenal, BUILTIN_TOOLS, EXTERNAL_TOOLS } from './arsenal/index.js';
+import {
+ Arsenal,
+ BUILTIN_TOOLS,
+ EXTERNAL_TOOLS,
+ stampSpicyBuiltin,
+ hostFromTargetValue,
+ scopeViolation,
+ runSubprocess,
+ isToolAvailable,
+} from './arsenal/index.js';
+import { buildAdapterTools } from './arsenal/adapter-tools.js';
+import { buildPostExTools } from './arsenal/post-ex.js';
+import { ApprovalController, type ApprovalRequest } from './arsenal/approval.js';
+import { TOOL_ADAPTERS } from './arsenal/catalog.js';
import { OpsecController, createBalancedOpsecConfig } from './opsec/index.js';
import { CommsChannel } from './comms/index.js';
import { AnalysisEngine } from './analysis/index.js';
@@ -263,6 +276,8 @@ export class TempestCommand extends EventEmitter {
public readonly targetEnv: TargetEnvironment;
public readonly vault: EvidenceVault;
public readonly arsenal: Arsenal;
+ /** Capability approval + spicy-action warning gate for intrusive/dangerous tools. */
+ public readonly approval: ApprovalController;
public readonly opsec: OpsecController;
public readonly comms: CommsChannel;
public readonly analysis: AnalysisEngine;
@@ -331,10 +346,57 @@ export class TempestCommand extends EventEmitter {
this.opsec = new OpsecController(config.opsec);
this.comms = new CommsChannel();
- // Register built-in tools and external CLI wrappers
- this.arsenal.registerMany(BUILTIN_TOOLS);
+ // Register built-in tools and external CLI wrappers. The built-in intrusive/credential probes
+ // (sqli_scan, password_spray, β¦) are the pre-existing honest baseline and stay UNGATED by default β
+ // zero regression: the headline benchmark and every prior run keep firing them freely. Opt in with
+ // T3MP3ST_GATE_BUILTINS=1 to stamp the spicy ones with a riskTier so the same approval gate that
+ // fences the specialist arsenal (metasploit/hydra) also fences them.
+ const gateBuiltins = /^(1|true|yes|on)$/i.test(process.env.T3MP3ST_GATE_BUILTINS ?? '');
+ this.arsenal.registerMany(gateBuiltins ? BUILTIN_TOOLS.map(stampSpicyBuiltin) : BUILTIN_TOOLS);
this.arsenal.registerMany(EXTERNAL_TOOLS);
+ // Capability approval + spicy-action warning gate. An intrusive/credential/dangerous tool is
+ // INERT until it's approved. Two ways in: (1) headless β a pre-authorization allowlist up front
+ // via T3MP3ST_APPROVED_TOOLS (comma list) runs those tools free; (2) interactive β a host wires an
+ // approver so the operator approves a tool once, then it's free. No approver + not pre-approved =
+ // fail-safe DENY (an unattended run never self-fires an exploit). Every gated call is audited; the
+ // spicy ones (exploits / cred attacks) surface a loud warning. Wired onto the arsenal below so the
+ // gate runs inside Arsenal.execute() alongside the egress scope gate.
+ const preApprovedTools = (process.env.T3MP3ST_APPROVED_TOOLS ?? '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+ this.approval = new ApprovalController({
+ preApprovedTools,
+ onWarning: (req: ApprovalRequest) => {
+ // Loud, non-blocking warning so a spicy action is always SEEN. A host UI can also read
+ // this.approval.getAudit() or replace the controller for a richer surface.
+ // eslint-disable-next-line no-console
+ console.warn(`β οΈ SPICY ACTION [${req.risk}] ${req.operator ? req.operator + ' β ' : ''}${req.action}`);
+ },
+ // Bridge every gated decision to the dashboard's live approval/audit feed (connectBroadcast
+ // forwards this engine event to the SSE channel as `arsenal.approval`).
+ onDecision: (record) => this.emit('approval:decision', record),
+ });
+ this.arsenal.setApprovalController(this.approval);
+
+ // Phase-1 (OPT-IN): arm the specialist arsenal. Gated behind T3MP3ST_FULL_ARSENAL so the honest
+ // bash-only benchmark baseline (built-ins only) stays uncontaminated β a full-power / pack hunt
+ // sets it. The generic factory NEVER mints catalog_only/import_only adapters; the post-ex drivers
+ // (metasploit/hydra) are hand-written and each carries a riskTier so the approval gate above fences
+ // them. The egress scope gate in Arsenal.execute() still fences every target; the in-handler
+ // scopeOk here is a second belt-and-braces check on the resolved per-adapter target.
+ if (/^(1|true|on)$/i.test(process.env.T3MP3ST_FULL_ARSENAL ?? '')) {
+ const deps = {
+ runSubprocess,
+ isToolAvailable,
+ scopeOk: (target: string) => scopeViolation(this.arsenal.getScope(), { parameters: { target } }) === null,
+ };
+ const existing = new Set(this.arsenal.getAllTools().map((t) => t.name));
+ this.arsenal.registerMany(buildAdapterTools(TOOL_ADAPTERS, deps, existing));
+ this.arsenal.registerMany(buildPostExTools(deps)); // metasploit_module (dangerous) + hydra_bruteforce (credential)
+ }
+
// Advanced modules
this.exploit = new ExploitEngine();
this.scanner = new ScannerOrchestrator();
@@ -424,6 +486,7 @@ export class TempestCommand extends EventEmitter {
// Only mark "seeded" if a mission actually exists β otherwise generateTasksForTarget
// no-ops and we'd falsely suppress the tick-loop seeding (leaving operators idle).
this.targetEnv.on('target:added', (target) => {
+ this.syncArsenalScope();
if (this.mission.getActiveMission()) {
this.mission.generateTasksForTarget(target.address);
this.taskSeeded = true;
@@ -431,6 +494,19 @@ export class TempestCommand extends EventEmitter {
});
}
+ /**
+ * Recompute the arsenal's authorized egress scope from the mission's targets. Operators can only
+ * reach the authorized target hosts (+ loopback + lab/private ranges); every other host is refused
+ * at arsenal.execute() before the handler runs. Called whenever a target is added, so a keyless
+ * operator can never point a networked tool at an off-target host.
+ */
+ private syncArsenalScope(): void {
+ const allowedHosts = this.targetEnv.getAllTargets()
+ .map((t) => hostFromTargetValue(t.address))
+ .filter((h): h is string => !!h);
+ this.arsenal.setScope({ allowedHosts, allowLoopback: true, allowPrivate: true });
+ }
+
/**
* Setup event forwarding for an operator
*/
@@ -951,6 +1027,7 @@ export class TempestCommand extends EventEmitter {
this.on('credential:harvested', (data) => broadcast('credential', data));
this.on('detection:triggered', (data) => broadcast('detection', data));
this.on('mission:phase_changed', (data) => broadcast('phase_changed', data));
+ this.on('approval:decision', (data) => broadcast('arsenal.approval', data));
this.on('tick', (count) => {
// Broadcast status every 5 ticks to avoid flooding
if (typeof count === 'number' && count % 5 === 0) {
@@ -973,12 +1050,14 @@ export class TempestCommand extends EventEmitter {
const operator = this.cell.spawnOperator(callsign, archetype);
this.setupOperatorEvents(operator);
- // Attach the agent loop scoped to this archetype's tool categories
+ // Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools =
+ // the curated per-operator tool allowlist). toolCategories stays as a coarse fallback.
const profile = ARCHETYPE_PROFILES[archetype];
const agentLoop = new AgentLoop(this.llm, this.arsenal, {
maxIterations: 15,
maxTokens: 50000,
toolCategories: profile.toolCategories,
+ tools: profile.defaultTools,
});
operator.attachArsenal(this.arsenal, agentLoop);
@@ -1067,6 +1146,7 @@ export interface Tempest {
targetEnv: TargetEnvironment;
vault: EvidenceVault;
arsenal: Arsenal;
+ approval: ApprovalController;
opsec: OpsecController;
comms: CommsChannel;
analysis: AnalysisEngine;
@@ -1110,6 +1190,7 @@ export function createTempest(config: TempestConfig): Tempest {
targetEnv: command.targetEnv,
vault: command.vault,
arsenal: command.arsenal,
+ approval: command.approval,
opsec: command.opsec,
comms: command.comms,
analysis: command.analysis,
diff --git a/src/mission/adjudicate.ts b/src/mission/adjudicate.ts
new file mode 100644
index 0000000..4a29ac9
--- /dev/null
+++ b/src/mission/adjudicate.ts
@@ -0,0 +1,558 @@
+/**
+ * Shared adjudicate + cite-check (Phase-0 #5).
+ *
+ * The single, PURE home for the two safety primitives the disclosure CLI
+ * (scripts/refute-finding.mjs) and the live pack-audit path
+ * (src/mission/audit-coordinator.ts, per the pack-hunt design)
+ * must SHARE so the two paths can't drift:
+ *
+ * 1. adjudicate(verdicts) β deterministic strict-majority tally.
+ * 2. guardExistsInSource(guard, src) β deterministic cite-check that a cited
+ * killing guard actually appears in source.
+ *
+ * Both are ported verbatim (in behavior) from scripts/refute-finding.mjs. The
+ * ONLY intentional change: guardExistsInSource here takes the SOURCE TEXT as its
+ * second argument (injected by the caller) and returns a plain boolean, so this
+ * module stays pure β no `fs`, no repo-root, no I/O. The refute CLI reads the file
+ * and passes the text in; the live path passes the already-loaded source.
+ *
+ * Why this matters (the pack-hunt design HT-2): a false REFUTE built on a
+ * hallucinated "killing guard" kills a real 0-day AND the dedup guard then
+ * permanently blocks re-finding it. So the cite-check is MANDATORY, non-optional:
+ * a REFUTED vote whose killing_guard is not in source is downgraded to SURVIVED
+ * before the tally (downgradeUnverifiedRefutes()).
+ *
+ * No engine imports β pure functions only.
+ */
+
+// =============================================================================
+// TYPES
+// =============================================================================
+
+/** The two decisive verdicts a single refuter/auditor can cast. */
+export type RefuterVerdict = 'REFUTED' | 'SURVIVED';
+
+/** The adjudicated panel verdict. */
+export type PanelVerdict = 'REFUTED' | 'SURVIVED' | 'INCONCLUSIVE';
+
+/**
+ * A cited guard the refuter claims dominates the path from untrusted input to the
+ * sink (a bounds check, a normalize pre-pass, a CA binding, ...). `quote` is the
+ * guard's source text, used by guardExistsInSource() as the cite-check anchor.
+ */
+export interface KillingGuard {
+ file: string;
+ line: number;
+ quote: string;
+}
+
+/**
+ * One refuter/auditor vote. A `null` entry (a crashed/timed-out refuter) or a
+ * `verdict` outside {REFUTED,SURVIVED} counts as an abstention β it never adds to
+ * the refute count and never to the panel size.
+ */
+export interface RefuterVote {
+ verdict: RefuterVerdict | string;
+ killing_guard?: KillingGuard | null;
+ /** Populated by downgradeUnverifiedRefutes when a REFUTED vote fails the cite-check. */
+ original_verdict?: RefuterVerdict;
+ guard_check?: 'verified' | 'unverified';
+ [k: string]: unknown;
+}
+
+/** The deterministic tally result. */
+export interface Adjudication {
+ verdict: PanelVerdict;
+ /** Number of counted REFUTED votes (post-downgrade, if the caller downgraded). */
+ refutedCount: number;
+ /** Size of the valid (non-abstaining) panel. */
+ total: number;
+ /** The killing guards from the counted REFUTED votes, nulls dropped. */
+ killing_guards: KillingGuard[];
+}
+
+// =============================================================================
+// ADJUDICATE β deterministic strict-majority tally
+// =============================================================================
+
+/**
+ * Tally refuter/auditor votes into a single panel verdict.
+ *
+ * Rules (ported from refute-finding.mjs `adjudicate`):
+ * - A vote is VALID only if verdict === 'REFUTED' || verdict === 'SURVIVED'.
+ * `null`, undefined, and any other verdict string (junk, abstention) are
+ * dropped β they count toward NEITHER the refute count NOR the panel size.
+ * - REFUTED requires a STRICT majority of the valid panel: refuted*2 > total.
+ * A tie (exactly half) does NOT refute β SURVIVED wins ties.
+ * - Empty valid panel (n === 0) β INCONCLUSIVE.
+ * - Otherwise β SURVIVED.
+ *
+ * NOTE: this is the tally ONLY. To honor the mandatory cite-check
+ * (the pack-hunt design HT-2) the caller must first pass the votes through
+ * downgradeUnverifiedRefutes() so a REFUTED built on a hallucinated guard has
+ * already been turned into SURVIVED before it's counted here.
+ */
+export function adjudicate(verdicts: ReadonlyArray): Adjudication {
+ const valid = verdicts.filter(
+ (v): v is RefuterVote => v !== null && v !== undefined && (v.verdict === 'REFUTED' || v.verdict === 'SURVIVED'),
+ );
+ const refuted = valid.filter((v) => v.verdict === 'REFUTED');
+ const n = valid.length;
+ const majorityRefuted = n > 0 && refuted.length * 2 > n; // STRICT majority
+ return {
+ verdict: n === 0 ? 'INCONCLUSIVE' : majorityRefuted ? 'REFUTED' : 'SURVIVED',
+ refutedCount: refuted.length,
+ total: n,
+ killing_guards: refuted
+ .map((v) => v.killing_guard)
+ .filter((g): g is KillingGuard => g !== null && g !== undefined),
+ };
+}
+
+// =============================================================================
+// GUARD CITE-CHECK β deterministic, pure
+// =============================================================================
+
+const normalize = (s: string): string => String(s).replace(/\s+/g, '').toLowerCase();
+
+// Strip the wrapping punctuation the operand char-class greedily absorbs β the `(` a comparison
+// carries from `if (n > cap)`, and the trailing `)` β so a source operand `(n` / `cap)` compares
+// EQUAL to a cited `n` / `cap`. Also strip a LEADING variable sigil (`$`/`@`/`%`, as in shell/Perl/PHP/
+// Ruby) so a cited `n > cap` verifies against a source `$n > $cap`. Applied to BOTH cited and source
+// operands, so any residue cancels out identically on each side.
+const coreToken = (s: string): string => normalize(s).replace(/^[($@%\s]+/, '').replace(/[)\s;,]+$/, '');
+
+// A comparison expression: `a OP b`, OP one of >= <= == != > < . The operand class allows the
+// token chars real code uses around a comparison (identifiers, member access `a.b`, pointer
+// `a->b`, calls `f()`, array `a[i]`). Kept as a SOURCE STRING so each scan spins up a fresh /g
+// instance β a shared `/g` RegExp is stateful (lastIndex) and would desync across lines.
+const COMPARISON_SRC = '([\\w.()\\[\\]>_$@-]+)\\s*(>=|<=|==|!=|>|<)\\s*([\\w.()\\[\\]>_$@-]+)';
+const COMPARISON_RE = new RegExp(COMPARISON_SRC);
+
+// The operator seen when the SAME comparison is written with its operands reversed (`a > b` β‘ `b < a`).
+// Used by the structural cite-check so a cited `a OP b` can verify against a source `b MIRROR[OP] a`.
+// Equality/inequality are their own mirror; ordering operators flip.
+const MIRROR_OP: Record = { '>': '<', '<': '>', '>=': '<=', '<=': '>=', '==': '==', '!=': '!=' };
+
+// A quote only counts as a *guard* for the SUBSTRING path (Path 1) if it carries real guard SYNTAX: an
+// UNAMBIGUOUS comparison operator (>= <= == !=), a control-flow keyword, or a bounds/validate FUNCTION
+// CALL (keyword immediately followed by `(`). This stops Path 1 from "verifying" a hallucinated guard
+// that merely quotes one common source token like `length`. NOTE: bare `<` / `>` are DELIBERATELY
+// excluded here β a single `<`/`>` is indistinguishable from a generic angle bracket at the substring
+// level (a cited `vector < int` would otherwise match `std::vector`), so `a < b` / `a > b` guards
+// are verified only by the operand-checked structural path (Path 2), which captures the generic's `>`
+// into the operand and rejects it.
+const GUARD_SHAPE_RE =
+ /(?:>=|<=|==|!=)|\b(?:if|else|return|break|continue|throw|goto)\b|\b(?:assert|clamp|min|max|bound|bounded|validate|sanitize|require|abort|reject|checked?|verify|ensure|limit)\s*\(/i;
+
+/**
+ * Per-language lexical traits: which comment + string forms a file uses, and whether it has `/β¦/`
+ * REGEX literals. Distinguishing a JS regex `/β¦/` from a division `a / b` needs full JS grammar (four
+ * hand-rolled scanners each leaked), so codeLinesOf never tries: for a regex-bearing language it just
+ * blanks each line past its first `/` (any regex body dies with it β leak-free by construction); a
+ * no-regex language keeps divisions verbatim (full fidelity). Comment/string forms ARE lexable per
+ * language, so they are stripped precisely. Unknown extensions get the SAFE superset (regex-aware +
+ * every common line-comment marker) so a mis-classified file over-blanks (a false-REJECT/SURVIVED)
+ * rather than leaking.
+ */
+interface LangTraits {
+ regexAware: boolean; // `/β¦/` regex literals β blank each line past its first `/`
+ slashComment: boolean; // `//` line comment (NOT Python, where `//` is floor division)
+ blockComment: boolean; // `/* β¦ */`
+ hashComment: boolean; // `#` line comment (Python/Ruby/shell/YAML/β¦)
+ dashComment: boolean; // `--` line comment (SQL/Lua/Haskell/Ada)
+ pctComment: boolean; // `%` line comment (MATLAB/Erlang/TeX; also over-blanks Ruby `%qβ¦`/`%rβ¦` literals)
+ splice: boolean; // a `//` line comment continues across a trailing `\` (C/C++ line-splicing only)
+ pyTriple: boolean; // `"""` / `'''` multi-line strings (Python + Java/Kotlin/Scala/Swift text blocks)
+ template: boolean; // JS `` `β¦` `` template literal with `${β¦}` interpolation
+ rawBacktick: boolean; // Go `` `β¦` `` raw string (no escapes/interp); also the safe-superset backtick
+ cppRaw: boolean; // C++ raw string R"delim(β¦)delim" (+ u8/u/U/L prefixes)
+ rustRaw: boolean; // Rust raw string r#"β¦"# (+ b prefix)
+ heredoc: 'shell' | 'ruby' | 'php' | false; // `<): LangTraits => ({ ...base, ...o });
+ const ext = (String(file ?? '').toLowerCase().match(/\.([a-z0-9+#]+)$/) ?? [])[1] ?? '';
+ if (/^(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(ext)) return set({ regexAware: true, slashComment: true, blockComment: true, template: true });
+ if (/^(cpp|cxx|cc|hpp|hh|hxx|c\+\+|ino|cppm|ixx|tpp|ipp)$/.test(ext)) return set({ slashComment: true, blockComment: true, splice: true, cppRaw: true });
+ if (/^(c|h)$/.test(ext)) return set({ slashComment: true, blockComment: true, splice: true });
+ if (/^go$/.test(ext)) return set({ slashComment: true, blockComment: true, rawBacktick: true });
+ if (/^rs$/.test(ext)) return set({ slashComment: true, blockComment: true, rustRaw: true });
+ if (/^(java|kt|kts|scala|swift|cs|dart|groovy|gradle)$/.test(ext)) return set({ slashComment: true, blockComment: true, pyTriple: true });
+ if (/^(py|pyi|pyw|pyx)$/.test(ext)) return set({ hashComment: true, pyTriple: true });
+ if (/^(sh|bash|zsh|ksh)$/.test(ext)) return set({ hashComment: true, heredoc: 'shell' });
+ if (/^(fish|yaml|yml|tf|toml|ini|cfg|conf|dockerfile|mk|cmake|jl|nim|coffee|properties|gitignore)$/.test(ext)) return set({ hashComment: true });
+ if (/^php\d*$/.test(ext)) return set({ slashComment: true, blockComment: true, hashComment: true, heredoc: 'php' });
+ if (/^(sql|hql|psql|pgsql|ddl)$/.test(ext)) return set({ dashComment: true, blockComment: true, dollarQuote: true });
+ if (/^(lua|hs|lhs|elm|adb|ads)$/.test(ext)) return set({ dashComment: true });
+ if (/^(erl|hrl|tex|latex)$/.test(ext)) return set({ pctComment: true });
+ return set({ regexAware: true, slashComment: true, blockComment: true, hashComment: true, dashComment: true, pctComment: true, pyTriple: true, rawBacktick: true, heredoc: 'ruby', podDoc: true, perlQuote: true });
+}
+
+/**
+ * Reduce source to code-only lines: strip comments and BLANK string / char / template / raw-string /
+ * regex literal contents (per the file's LangTraits), so the cite-check never matches a guard that
+ * lives only in a comment / string / regex β the HT-2 false-VERIFY hazard. Newlines are preserved so
+ * matching stays per-line.
+ *
+ * Handles, per language: line comments (slash-slash, hash, dash-dash, percent) and slash-star block
+ * comments (only slash-slash splices across a trailing backslash, C/C++ only);
+ * ' / " strings (line-bounded, LF+CRLF `\`-continuation); Python/JVM `"""`/`'''`; JS `${β¦}` templates
+ * (interpolation-stack aware, so a nested template can't leak); Go `` ` `` raw strings; C++ R"d(β¦)d" and
+ * Rust r#"β¦"# raw strings; and shell/Ruby `< = []; // pending terminators, drained at newline
+ const isIdent = (ch: string | undefined): boolean => ch !== undefined && /[A-Za-z0-9_$]/.test(ch);
+ const blank = (len: number): void => { if (len > 0) masked += ' '.repeat(len); };
+ const emitEsc = (i: number): number => {
+ if (src[i + 1] === '\n') { masked += '\n'; return i + 2; } // `\`-LF line continuation
+ if (src[i + 1] === '\r' && src[i + 2] === '\n') { masked += '\n'; return i + 3; } // `\`-CRLF
+ masked += ' '; return i + 2;
+ };
+ const skipLineComment = (i: number, splice: boolean): number => {
+ for (;;) {
+ while (i < n && src[i] !== '\n') i++;
+ if (splice) { let k = i - 1; if (src[k] === '\r') k--; if (k >= 0 && src[k] === '\\') { masked += '\n'; i++; continue; } }
+ return i;
+ }
+ };
+ const blankToClose = (j: number, close: string): number => {
+ while (j < n && src.slice(j, j + close.length) !== close) { masked += src[j] === '\n' ? '\n' : ' '; j++; }
+ const end = Math.min(j + close.length, n);
+ blank(end - j);
+ return end;
+ };
+ const PERL_PAIR: Record = { '(': ')', '{': '}', '[': ']', '<': '>' };
+ // Perl quote-like operator (q qq qw qr qx m β 1 body; s tr y β 2 bodies) at position i, or -1.
+ const scanPerlQuote = (i: number): number => {
+ const m = src.slice(i, i + 3).match(/^(qq|qw|qr|qx|tr|q|m|s|y)(?![A-Za-z0-9_])/);
+ if (!m) return -1;
+ const op = m[1];
+ let j = i + op.length;
+ while (src[j] === ' ' || src[j] === '\t') j++;
+ const open = src[j];
+ // a valid delimiter is a single non-alnum, non-space char that isn't an operator/closer that would
+ // make ordinary code (e.g. `s = 1`, `$h->{q}`, `$a // $b`) look like a quote op and over-run.
+ if (!open || /[A-Za-z0-9_\s=,;)\]}>]/.test(open)) return -1;
+ const twoBody = op === 's' || op === 'tr' || op === 'y';
+ blank(j - i + 1); j++; // op + opening delimiter
+ const scanBody = (o: string, cl: string): void => {
+ let depth = 1;
+ while (j < n) {
+ const d = src[j];
+ if (d === '\\') { masked += ' '; masked += src[j + 1] === '\n' ? '\n' : ' '; j += 2; continue; }
+ if (PERL_PAIR[o] && d === o) depth++;
+ else if (d === cl) { depth--; if (depth === 0) { blank(1); j++; return; } }
+ masked += d === '\n' ? '\n' : ' '; j++;
+ }
+ };
+ scanBody(open, PERL_PAIR[open] ?? open);
+ if (twoBody) {
+ let o2 = open, c2 = PERL_PAIR[open] ?? open;
+ if (PERL_PAIR[open]) { while (src[j] === ' ' || src[j] === '\t') j++; o2 = src[j]; c2 = PERL_PAIR[o2] ?? o2; if (o2) { blank(1); j++; } }
+ scanBody(o2, c2); // a char delimiter's close doubles as the 2nd body's open (already consumed)
+ }
+ return j;
+ };
+ const scanTemplate = (i: number): number => {
+ masked += ' '; i++;
+ const stk = ['`']; // '`' = template-string part, '{' = ${β¦} interpolation, '"'/"'" = string in interp
+ while (i < n && stk.length > 0) {
+ const d = src[i];
+ if (d === '\\') { i = emitEsc(i); continue; }
+ const top = stk[stk.length - 1];
+ if (top === '`') {
+ if (d === '`') { stk.pop(); masked += ' '; i++; continue; }
+ if (d === '$' && src[i + 1] === '{') { stk.push('{'); masked += ' '; i += 2; continue; }
+ } else if (top === '"' || top === "'") {
+ if (d === top) { stk.pop(); masked += ' '; i++; continue; }
+ if (d === '\n') { stk.pop(); masked += '\n'; i++; continue; }
+ } else { // top === '{' : a ${β¦} interpolation β BLANK it. A comparison there produces template
+ if (d === '}') { stk.pop(); masked += ' '; i++; continue; } // display text, not a bounds guard;
+ if (d === '{') { stk.push('{'); masked += ' '; i++; continue; } // and an interpolation reached
+ if (d === '`') { stk.push('`'); masked += ' '; i++; continue; } // through a nested template must
+ if (d === '"' || d === "'") { stk.push(d); masked += ' '; i++; continue; } // never leak (false-VERIFY).
+ }
+ masked += d === '\n' ? '\n' : ' '; i++;
+ }
+ return i;
+ };
+ for (let i = 0; i < n; ) {
+ const c = src[i];
+ const c2 = src[i + 1];
+ // ---- Perl POD doc blocks (=word β¦ =cut) + __END__/__DATA__ trailer (both line-anchored) ----
+ if (t.podDoc && (i === 0 || src[i - 1] === '\n')) {
+ let le = i; while (le < n && src[le] !== '\n') le++;
+ // __END__/__DATA__ must be at column 0 to be a Perl trailer (an indented one is ordinary code, so
+ // test the line FROM its first char, not a trimmed copy β else an indented match over-blanks to EOF).
+ if (/^__(?:END|DATA)__\b/.test(src.slice(i, le))) { while (i < n) { masked += src[i] === '\n' ? '\n' : ' '; i++; } continue; }
+ // A `=word` at column 0 opens POD (closed by `=cut`) β but ONLY if a `=cut` line actually appears
+ // later; otherwise a stray leading `=` (an expression continuation, config, diff) is real code and
+ // must not be blanked to EOF (that was a false-REJECT that killed real guards).
+ if (c === '=' && /[A-Za-z]/.test(c2) && /\n=cut\b/.test(src.slice(i))) {
+ for (;;) {
+ let j = i; while (j < n && src[j] !== '\n') j++;
+ const isCut = /^=cut\b/.test(src.slice(i, j));
+ blank(j - i); i = j;
+ if (i < n) { masked += '\n'; i++; }
+ if (isCut || i >= n) break;
+ }
+ continue;
+ }
+ }
+ // ---- Perl quote-like operators (q// qq// s/// tr/// m// qr// qw[] β¦) ----
+ if (t.perlQuote && (c === 'q' || c === 'm' || c === 's' || c === 'y' || c === 't') && !isIdent(src[i - 1])) {
+ const j = scanPerlQuote(i);
+ if (j >= 0) { i = j; continue; }
+ }
+ // ---- line comments ----
+ if (t.slashComment && c === '/' && c2 === '/') { i = skipLineComment(i, t.splice); continue; }
+ if (t.hashComment && c === '#') { i = skipLineComment(i, false); continue; }
+ if (t.dashComment && c === '-' && c2 === '-') { i = skipLineComment(i, false); continue; }
+ if (t.pctComment && c === '%') { i = skipLineComment(i, false); continue; }
+ // ---- block comment ----
+ if (t.blockComment && c === '/' && c2 === '*') {
+ i += 2;
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) { if (src[i] === '\n') masked += '\n'; i++; }
+ i += 2;
+ continue;
+ }
+ // ---- C++ raw string (u8|u|U|L)?R"delim(β¦)delim" ----
+ if (t.cppRaw && !isIdent(src[i - 1])) {
+ const m = src.slice(i, i + 4).match(/^(u8R|uR|UR|LR|R)"/);
+ if (m) {
+ let j = i + m[0].length, delim = '';
+ while (j < n && src[j] !== '(' && src[j] !== '\n' && src[j] !== ' ' && src[j] !== '\\' && delim.length <= 16) { delim += src[j]; j++; }
+ if (src[j] === '(') { blank(j - i + 1); i = blankToClose(j + 1, ')' + delim + '"'); continue; }
+ }
+ }
+ // ---- Rust raw string b?r#*"β¦"#* ----
+ if (t.rustRaw && !isIdent(src[i - 1])) {
+ const m = src.slice(i, i + 260).match(/^b?r(#*)"/); // wide enough for any real `r####β¦"` hash run
+ if (m) { blank(m[0].length); i = blankToClose(i + m[0].length, '"' + m[1]); continue; }
+ }
+ // ---- PHP heredoc / nowdoc <<<['"]?WORD['"]? (body drained at the newline) ----
+ // PHP opens a here/nowdoc with THREE `<`; its `<<` is a left-shift operator, never a heredoc. The
+ // closer is WORD alone on a line (PHP 7.3+ allows an indented closer, optionally followed by `;`, `,`,
+ // `)`, etc.), so the drain matches WORD at the line's first token rather than the whole line.
+ if (t.heredoc === 'php' && c === '<' && c2 === '<' && src[i + 2] === '<') {
+ let j = i + 3;
+ while (src[j] === ' ' || src[j] === '\t') j++;
+ let q = ''; if (src[j] === "'" || src[j] === '"') { q = src[j]; j++; }
+ let w = ''; while (j < n && /[A-Za-z0-9_]/.test(src[j])) { w += src[j]; j++; }
+ if (q && src[j] === q) j++;
+ if (w) { blank(j - i); heredocs.push({ w, indent: true, php: true }); i = j; continue; }
+ }
+ // ---- heredoc opener <<[-~]?['"]?WORD (body drained at the newline) ----
+ // Guard against `<<<` (shell here-string, not a multi-line heredoc): reject when either neighbour
+ // is another `<`, so neither the 1st nor the 2nd `<` of `<<<` opens a heredoc.
+ if ((t.heredoc === 'shell' || t.heredoc === 'ruby') && c === '<' && c2 === '<' && src[i + 2] !== '<' && src[i + 2] !== '=' && src[i - 1] !== '<') {
+ let j = i + 2;
+ const squig = src[j] === '-' || src[j] === '~'; if (squig) j++;
+ if (t.heredoc === 'shell') while (src[j] === ' ' || src[j] === '\t') j++;
+ let q = ''; if (src[j] === "'" || src[j] === '"' || src[j] === '`') { q = src[j]; j++; }
+ let w = ''; while (j < n && /[A-Za-z0-9_]/.test(src[j])) { w += src[j]; j++; }
+ if (q && src[j] === q) j++;
+ // shell: every < 0) {
+ const hd = heredocs.shift() as { w: string; indent: boolean; php?: boolean };
+ for (;;) {
+ let j = i; while (j < n && src[j] !== '\n') j++;
+ const stripped = src.slice(i, j).replace(/\r$/, '');
+ blank(j - i); i = j; // blank this body / terminator line
+ // PHP: closer is WORD as the line's first token (indent + trailing `;`/`,`/`)` allowed). Others:
+ // whole-line match (shell/ruby `<= n) break;
+ }
+ }
+ continue;
+ }
+ masked += c; i++;
+ }
+ const lines = masked.split('\n');
+ if (!t.regexAware) return lines; // no regex literals β every `/` is a division, kept verbatim
+ return lines.map((line) => {
+ const first = line.indexOf('/'); // every regex opens with `/` β blank past it, its body dies too
+ return first === -1 ? line : line.slice(0, first) + ' '.repeat(line.length - first);
+ });
+}
+
+/**
+ * Cite-check: does the refuter's cited killing `guard` actually appear in `source`?
+ *
+ * Deterministic, and the ONLY thing standing between a hallucinated "if (m_id >= ls.w) continue;"
+ * and a wrongly-refuted real finding (the pack-hunt design HT-2). A guard verifies two ways β
+ * BOTH hardened so a fabricated guard cannot pass on token coincidence alone:
+ *
+ * 1. NORMALIZED SUBSTRING: the whitespace-stripped, lowercased quote is a substring of the
+ * normalized source AND the quote has real guard SHAPE (a comparison operator / control keyword /
+ * bounds-call). A bare common token (`length`, or a copy of the sink line) has no guard shape and
+ * cannot verify. Tolerates reformatting/indentation.
+ * 2. STRUCTURAL COMPARISON: the quote is a comparison `a OP b`, and SOME source line contains a REAL
+ * comparison whose two operands are exactly `a` and `b` (whole tokens, either order). Mere
+ * co-occurrence of the two operand strings on a line is NOT enough β that was the hole that let
+ * `n > src` / `idx >= width` (operands that never actually get compared) mint a bogus REFUTE and
+ * kill a real finding.
+ *
+ * @param guard the cited guard; needs a non-empty `quote`.
+ * @param source the FULL text of the cited source file (injected by the caller β this module does no
+ * I/O). Pass '' if the file is missing β false.
+ * @returns true iff the guard is corroborated by the source; false if the quote is empty/too-short,
+ * lacks guard shape, the source is empty, or the cited operands are not actually compared in
+ * source (hallucinated / paraphrased-absent).
+ */
+export function guardExistsInSource(guard: KillingGuard | null | undefined, source: string): boolean {
+ if (!guard || !guard.quote) return false;
+ const src = String(source ?? '');
+ if (!src) return false;
+ const rawQuote = String(guard.quote);
+
+ // Reduce to code-only lines: strip comments AND blank string/char/template-literal (and, for a
+ // regex-bearing language, regex-literal) contents, then match PER LINE β so a hallucinated guard
+ // whose text sits only in a STRING, COMMENT, or REGEX can never verify. The language is decided from
+ // the cited file's extension (default: regex-aware / leak-safe) so a C/C++/β¦ source keeps its
+ // divisions verbatim while a JS/TS/unknown source is over-blanked past any `/`.
+ const codeLines = codeLinesOf(src, langTraits(String(guard.file ?? '')));
+
+ // Path 1 β normalized substring on a SINGLE code line, only for quotes that look like a guard.
+ const q = normalize(rawQuote);
+ if (q.length >= 6 && GUARD_SHAPE_RE.test(rawQuote)) {
+ for (const line of codeLines) if (normalize(line).includes(q)) return true;
+ }
+
+ // Path 2 β the cited comparison's operands must be the operands of a REAL comparison on ONE source
+ // line, not two strings that merely co-occur, AND with a matching OPERATOR. The operator is the whole
+ // semantic content of a bounds guard: a hallucinated `idx >= len` must NOT verify against a source
+ // `idx == len` (a non-bounding equality) just because they share operands. So we require the source
+ // operator to equal the cited one (same operand order) or its mirror (reversed order).
+ const cited = rawQuote.match(COMPARISON_RE);
+ if (cited) {
+ const a = coreToken(cited[1]);
+ const b = coreToken(cited[3]);
+ const op = cited[2];
+ if (a && b && a !== b) {
+ for (const line of codeLines) {
+ const re = new RegExp(COMPARISON_SRC, 'g');
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(line)) !== null) {
+ const la = coreToken(m[1]);
+ const lb = coreToken(m[3]);
+ const lop = m[2];
+ // A source `<` whose right operand is immediately followed by `,`, `>`, or `<` opens a GENERIC
+ // argument list (`Map`, `Vec`, `Nested>`), not a comparison β skip it. A
+ // single-arg generic (`Box`) is already rejected because the operand class captures its `>`
+ // (`t>` β `t`); this closes the multi-arg case where a comma cleanly ends the operand.
+ if (lop === '<') {
+ const nextCh = (line.slice(re.lastIndex).match(/^\s*(\S)/) ?? [])[1];
+ if (nextCh === ',' || nextCh === '>' || nextCh === '<') continue;
+ }
+ if (la === a && lb === b && lop === op) return true; // same order, same operator
+ if (la === b && lb === a && lop === MIRROR_OP[op]) return true; // reversed order, mirrored operator
+ }
+ }
+ }
+ }
+ return false;
+}
+
+// =============================================================================
+// MANDATORY CITE-CHECK ENFORCEMENT
+// =============================================================================
+
+/**
+ * Apply the mandatory cite-check to a panel BEFORE adjudicate().
+ *
+ * For every REFUTED vote that carries a killing_guard: if guardExistsInSource() is
+ * false, the vote is DOWNGRADED to SURVIVED (its original verdict preserved on
+ * `original_verdict` and `guard_check:'unverified'` recorded for transparency). A
+ * REFUTED vote with NO killing_guard also can't stand on a cite β it too is
+ * downgraded. This enforces HT-2: agreement can never promote a hallucinated
+ * refute; only a cited-and-verified guard refutes.
+ *
+ * Pure: returns a NEW array of NEW vote objects; input is not mutated. `null`
+ * entries and non-REFUTED votes pass through unchanged (copied).
+ *
+ * @param verdicts the raw panel votes.
+ * @param resolveSource maps a guard's `file` to that file's source text (the
+ * caller injects I/O here β e.g. read from disk, or look up an in-memory
+ * code map). Return '' (or undefined) for a missing file β the vote fails
+ * the check and is downgraded.
+ */
+export function downgradeUnverifiedRefutes(
+ verdicts: ReadonlyArray,
+ resolveSource: (file: string) => string | undefined,
+): Array {
+ return verdicts.map((v) => {
+ if (v === null || v === undefined) return null;
+ if (v.verdict !== 'REFUTED') return { ...v };
+ const guard = v.killing_guard ?? null;
+ const source = guard ? resolveSource(guard.file) ?? '' : '';
+ if (guard && guardExistsInSource(guard, source)) {
+ return { ...v, guard_check: 'verified' as const };
+ }
+ // hallucinated / paraphrased-absent / missing-guard β cannot refute.
+ return { ...v, original_verdict: 'REFUTED' as const, verdict: 'SURVIVED' as const, guard_check: 'unverified' as const };
+ });
+}
diff --git a/src/operators/index.ts b/src/operators/index.ts
index a74b976..da141f3 100755
--- a/src/operators/index.ts
+++ b/src/operators/index.ts
@@ -73,7 +73,7 @@ export const ARCHETYPE_PROFILES: Record = {
description: 'Specialized in OSINT, network discovery, and asset enumeration',
mitreTactics: ['TA0043'],
primaryPhases: [KillChainPhase.RECON],
- defaultTools: ['nmap_scan', 'port_scan', 'dns_lookup', 'subdomain_enum', 'whois_lookup', 'reverse_dns', 'technology_detect'],
+ defaultTools: ['dns_lookup', 'reverse_dns', 'whois_lookup', 'subdomain_enum', 'nmap_scan', 'port_scan', 'network_trace', 'version_detect', 'robots_txt_fetch', 'cidr_expand', 'technology_detect', 'http_request', 'curl_request', 'header_analysis', 'api_endpoint_discovery'],
toolCategories: ['recon', 'web'],
capabilities: ['osint', 'dns_enum', 'subdomain_discovery', 'port_scanning', 'service_detection'],
techniques: ['T1595', 'T1592', 'T1589', 'T1590', 'T1591'],
@@ -84,7 +84,7 @@ export const ARCHETYPE_PROFILES: Record = {
description: 'Identifies vulnerabilities and security misconfigurations',
mitreTactics: ['TA0007'],
primaryPhases: [KillChainPhase.WEAPONIZE],
- defaultTools: ['nuclei_scan', 'ssl_scan', 'cors_check', 'csp_analysis', 'header_analysis', 'http_methods_test', 'api_endpoint_discovery'],
+ defaultTools: ['nuclei_scan', 'ssl_scan', 'cors_check', 'csp_analysis', 'clickjacking_test', 'cookie_analysis', 'http_methods_test', 'open_redirect_test', 'port_scan', 'version_detect', 'technology_detect', 'api_endpoint_discovery', 'header_analysis', 'http_request', 'curl_request'],
toolCategories: ['vuln', 'web', 'recon'],
capabilities: ['vuln_scanning', 'web_scanning', 'service_enum', 'config_audit'],
techniques: ['T1046', 'T1082', 'T1083', 'T1087'],
@@ -95,7 +95,7 @@ export const ARCHETYPE_PROFILES: Record = {
description: 'Executes exploits and achieves initial access',
mitreTactics: ['TA0001', 'TA0002'],
primaryPhases: [KillChainPhase.DELIVER, KillChainPhase.EXPLOIT],
- defaultTools: ['sqli_scan', 'xss_scan', 'ssti_test', 'lfi_test', 'open_redirect_test', 'ffuf_fuzz', 'dir_bruteforce', 'http_request'],
+ defaultTools: ['sqli_scan', 'xss_scan', 'ssti_test', 'lfi_test', 'open_redirect_test', 'nuclei_scan', 'ffuf_fuzz', 'dir_bruteforce', 'api_endpoint_discovery', 'http_methods_test', 'password_spray', 'hash_crack', 'base64_decode', 'url_encode', 'jwt_decode', 'http_request', 'curl_request', 'technology_detect', 'header_analysis'],
toolCategories: ['vuln', 'web', 'auth', 'util'],
capabilities: ['exploit_dev', 'payload_delivery', 'initial_access', 'code_execution'],
techniques: ['T1190', 'T1133', 'T1078', 'T1059'],
@@ -106,7 +106,7 @@ export const ARCHETYPE_PROFILES: Record = {
description: 'Moves through networks and escalates privileges',
mitreTactics: ['TA0008', 'TA0004'],
primaryPhases: [KillChainPhase.INSTALL],
- defaultTools: ['hash_crack', 'password_spray', 'jwt_decode', 'http_request'],
+ defaultTools: ['hash_crack', 'password_spray', 'jwt_decode', 'cookie_analysis', 'dns_lookup', 'port_scan', 'subdomain_enum', 'network_trace', 'nmap_scan', 'sqli_scan', 'lfi_test', 'cve_lookup', 'base64_decode', 'http_request', 'curl_request', 'technology_detect'],
toolCategories: ['recon', 'web', 'auth', 'vuln'],
capabilities: ['priv_esc', 'lateral_movement', 'credential_access', 'domain_enum'],
techniques: ['T1021', 'T1078', 'T1068', 'T1548'],
@@ -117,7 +117,7 @@ export const ARCHETYPE_PROFILES: Record = {
description: 'Collects and extracts sensitive data',
mitreTactics: ['TA0009', 'TA0010'],
primaryPhases: [KillChainPhase.ACTIONS],
- defaultTools: ['http_request', 'curl_request', 'api_endpoint_discovery'],
+ defaultTools: ['http_request', 'curl_request', 'api_endpoint_discovery', 'dir_bruteforce', 'base64_decode', 'url_encode', 'jwt_decode', 'cookie_analysis', 'subdomain_enum', 'robots_txt_fetch', 'technology_detect', 'lfi_test', 'sqli_scan', 'header_analysis', 'cve_lookup'],
toolCategories: ['web', 'recon', 'util'],
capabilities: ['data_collection', 'exfiltration', 'staging', 'compression'],
techniques: ['T1041', 'T1048', 'T1567', 'T1560'],
@@ -128,7 +128,7 @@ export const ARCHETYPE_PROFILES: Record = {
description: 'Establishes persistence and covers tracks',
mitreTactics: ['TA0003', 'TA0005'],
primaryPhases: [KillChainPhase.INSTALL, KillChainPhase.C2],
- defaultTools: ['http_request', 'header_analysis', 'cookie_analysis', 'clickjacking_test'],
+ defaultTools: ['http_request', 'curl_request', 'cookie_analysis', 'header_analysis', 'csp_analysis', 'clickjacking_test', 'technology_detect', 'http_methods_test', 'jwt_decode', 'base64_decode', 'url_encode', 'robots_txt_fetch', 'subdomain_enum', 'open_redirect_test', 'ssl_scan'],
toolCategories: ['web', 'recon', 'vuln'],
capabilities: ['persistence', 'evasion', 'cleanup', 'anti_forensics'],
techniques: ['T1547', 'T1053', 'T1136', 'T1070'],
@@ -139,8 +139,8 @@ export const ARCHETYPE_PROFILES: Record = {
description: 'Orchestrates operations and manages agents',
mitreTactics: ['TA0011'],
primaryPhases: [KillChainPhase.C2],
- defaultTools: ['cve_lookup', 'technology_detect'],
- toolCategories: ['recon', 'web'],
+ defaultTools: ['cve_lookup', 'technology_detect', 'http_request', 'curl_request', 'header_analysis', 'nmap_scan', 'port_scan', 'dns_lookup', 'subdomain_enum', 'nuclei_scan', 'sqli_scan', 'api_endpoint_discovery', 'version_detect', 'ssl_scan'],
+ toolCategories: ['recon', 'web', 'vuln'],
capabilities: ['orchestration', 'task_management', 'communication', 'decision_making'],
techniques: ['T1071', 'T1095', 'T1573', 'T1132'],
systemPrompt: OPERATOR_SYSTEM_PROMPTS.coordinator,
@@ -150,8 +150,8 @@ export const ARCHETYPE_PROFILES: Record = {
description: 'Analyzes findings and generates reports',
mitreTactics: [],
primaryPhases: [KillChainPhase.ACTIONS],
- defaultTools: ['cve_lookup', 'jwt_decode', 'hash_crack', 'technology_detect'],
- toolCategories: ['web', 'vuln', 'recon'],
+ defaultTools: ['cve_lookup', 'jwt_decode', 'hash_crack', 'base64_decode', 'url_encode', 'technology_detect', 'ssl_scan', 'header_analysis', 'cors_check', 'csp_analysis', 'cookie_analysis', 'whois_lookup', 'dns_lookup', 'http_request', 'curl_request'],
+ toolCategories: ['web', 'vuln', 'recon', 'util'],
capabilities: ['analysis', 'reporting', 'recommendations', 'risk_assessment'],
techniques: [],
systemPrompt: OPERATOR_SYSTEM_PROMPTS.analyst,
diff --git a/src/pack/board.ts b/src/pack/board.ts
new file mode 100644
index 0000000..78b5fa0
--- /dev/null
+++ b/src/pack/board.ts
@@ -0,0 +1,548 @@
+/**
+ * T3MP3ST Pack Hunt β PackBoard (Phase-2 shared workspace / lead-board)
+ *
+ * An APPEND-ONLY event log + folded projections (a leads map, live claims, per-agent
+ * heartbeats), on the same EventEmitter discipline as `EvidenceVault`
+ * (src/evidence/index.ts). This is the pack's shared workspace: agents post leads, claim
+ * work so they don't re-tread, and endorse/refute each other β and each agent is threaded
+ * a BOUNDED `situationReport()` at task start instead of the raw board.
+ *
+ * See the pack-hunt design. The three red-team invariants this file
+ * is responsible for (Β§2):
+ * - HT-1: `claim()` is a REAL synchronous compare-and-set β no `await` between the version
+ * read and the write, so Node's single-threaded loop makes it genuinely atomic.
+ * - HT-1: de-dup collapses an honest duplicate into an *endorsement* that raises `smoke`,
+ * never a second lead card.
+ * - HT-2: `refute()` only APPENDS a refutation vote; it never unilaterally flips `status`.
+ * Flipping to refuted is the audit quorum's job (Phase-3), gated on `guardExistsInSource()`.
+ *
+ * The board mirrors lightweight finding *cards*; it is NOT the vault and does not gate
+ * provenance. `where.targetId` is the foreign key back to the provenance-gated `Finding.id`.
+ */
+
+import { EventEmitter } from 'eventemitter3';
+import { randomUUID } from 'crypto';
+import type { Severity } from '../types/index.js';
+import type {
+ AgentActivity,
+ AgentStatus,
+ BoardEvent,
+ Lead,
+ LeadProvenance,
+ LeadVote,
+} from './types.js';
+
+// =============================================================================
+// dedupKey β reused VERBATIM from scripts/cli-swarm.mjs (the `dedupKey` formula).
+// file::CWE::line-bucket(15). Two posts that map to the same key are the SAME lead.
+// We accept the cli-swarm finding shape ({file, cwe, line}) so a card built from a
+// specialist's JSON row keys identically to how the swarm already de-dups.
+// =============================================================================
+
+/**
+ * Collision key for de-dup. Extends cli-swarm's `file::CWE::line-bucket(15)` with two fixes:
+ * - vulnClass is folded in, so two DIFFERENT vuln classes at the same file:line-bucket (an XSS and
+ * an SQLi a line apart, or two classes when CWE is absent) never silently shadow each other.
+ * - a coordinate-less lead (no `file` β a recon/networked lead carrying only a `url`) keys on the
+ * surface + class, so every url-only lead doesn't collapse into a single empty-file-bucket card.
+ */
+export function dedupKey(f: { file?: string; cwe?: string; line?: number; vulnClass?: string; url?: string }): string {
+ const cls = `${(f.cwe || '').toUpperCase()}::${(f.vulnClass || '').toLowerCase()}`;
+ if (f.file) return `${f.file.toLowerCase()}::${cls}::${Math.round((f.line || 0) / 15)}`;
+ if (f.url) return `url::${f.url.toLowerCase()}::${cls}`;
+ return `::${cls}::${Math.round((f.line || 0) / 15)}`;
+}
+
+// =============================================================================
+// EVENTS β typed emit, same pattern as EvidenceVaultEvents.
+// `board:event` fires on EVERY mutation (the raw log entry); the specific events are
+// conveniences for subscribers that only care about one kind.
+// =============================================================================
+
+export interface PackBoardEvents {
+ 'board:event': BoardEvent;
+ 'lead:posted': Lead;
+ 'lead:endorsed': Lead;
+ 'lead:refuted': Lead;
+ 'lead:claimed': { lead: Lead; agentId: string };
+ 'lead:claim-denied': { leadId: string; agentId: string; version: number };
+ 'lead:claim-released': { lead: Lead; reason: 'expired' | 'released' };
+ 'lead:status-changed': { lead: Lead; from: Lead['status']; to: Lead['status'] };
+ 'agent:heartbeat': AgentStatus;
+}
+
+// =============================================================================
+// INPUT SHAPES β what a caller supplies. The board owns id / dedupKey / version /
+// smoke / timestamps / vote arrays, so callers can't forge them.
+// =============================================================================
+
+/** The caller-supplied fields of a new lead. The board derives everything else. If a
+ * `dedupKey` is not supplied it is computed from `where` + `cwe` via cli-swarm's formula. */
+export interface LeadInput {
+ kind: Lead['kind'];
+ title: string;
+ where: Lead['where'];
+ vulnClass: string;
+ confidence: Lead['confidence'];
+ provenance: LeadProvenance;
+ /** CWE id (e.g. "CWE-22") β folded into the dedupKey exactly like cli-swarm does. */
+ cwe?: string;
+ /** Explicit override of the computed dedupKey (rare; tests / cross-surface merges). */
+ dedupKey?: string;
+ severity?: Lead['severity'];
+}
+
+// =============================================================================
+// CONFIG β bounds that keep the situation report from ever dumping the raw board.
+// =============================================================================
+
+export interface PackBoardConfig {
+ /** Hard upper bound on the length of any `situationReport()` string. Never exceeded. */
+ reportCharCap: number;
+ /** Default number of top-smoke unclaimed leads to include when the caller omits maxLeads. */
+ defaultMaxLeads: number;
+ /** An agent whose last heartbeat is older than this (ms) is treated as `gone` / not live. */
+ livenessWindowMs: number;
+}
+
+export const DEFAULT_PACKBOARD_CONFIG: PackBoardConfig = {
+ reportCharCap: 4000,
+ defaultMaxLeads: 8,
+ livenessWindowMs: 90_000,
+};
+
+// =============================================================================
+// PACK BOARD
+// =============================================================================
+
+export class PackBoard extends EventEmitter {
+ /** The append-only log. Never mutated in place; only pushed. The single source of truth. */
+ private readonly log: BoardEvent[] = [];
+
+ /** Folded projection: the current leads, keyed by lead id. */
+ private readonly leads: Map = new Map();
+
+ /** Fast index dedupKey β leadId, so a duplicate post collapses in O(1). */
+ private readonly byDedup: Map = new Map();
+
+ /** Folded projection: latest heartbeat per agent. */
+ private readonly agents: Map = new Map();
+
+ private readonly cfg: PackBoardConfig;
+
+ /** Injected clock β defaults to Date.now, overridable so tests are deterministic. */
+ private readonly now: () => number;
+
+ constructor(config: Partial = {}, now: () => number = Date.now) {
+ super();
+ this.cfg = { ...DEFAULT_PACKBOARD_CONFIG, ...config };
+ this.now = now;
+ }
+
+ // ---------------------------------------------------------------------------
+ // internal: append one entry to the log + emit. The ONLY writer of `this.log`.
+ // ---------------------------------------------------------------------------
+ private append(
+ type: BoardEvent['type'],
+ actor: string,
+ leadId?: string,
+ data?: Record,
+ ): BoardEvent {
+ const ev: BoardEvent = { id: randomUUID(), type, actor, leadId, data, at: this.now() };
+ this.log.push(ev);
+ this.emit('board:event', ev);
+ return ev;
+ }
+
+ // ---------------------------------------------------------------------------
+ // internal helpers: evidence ranking + defensive snapshots.
+ // ---------------------------------------------------------------------------
+
+ /** A real tool reproduction outranks a context guess outranks none. */
+ private static provenanceRank(p: LeadProvenance): number {
+ return p === 'tool' ? 2 : p === 'context' ? 1 : 0;
+ }
+
+ /** Severity rank (higher = worse); unknown/undefined ranks lowest. */
+ private static severityRank(s?: Severity): number {
+ const rank: Record = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
+ return s ? rank[s] : -1;
+ }
+
+ /**
+ * Monotonically upgrade a card's evidence from a duplicate post: adopt the incoming provenance /
+ * severity ONLY when stronger than what the card already carries (never downgrade). Called on the
+ * de-dup collapse so a second, better-sourced find of the same bug isn't thrown away.
+ */
+ private upgradeEvidence(lead: Lead, input: { provenance: LeadProvenance; severity?: Severity }): void {
+ if (PackBoard.provenanceRank(input.provenance) > PackBoard.provenanceRank(lead.provenance)) {
+ lead.provenance = input.provenance;
+ }
+ if (input.severity && PackBoard.severityRank(input.severity) > PackBoard.severityRank(lead.severity)) {
+ lead.severity = input.severity;
+ }
+ }
+
+ /** A defensive deep copy of a lead. The board NEVER hands out a live reference to its internal
+ * projection β a caller mutating `version`/`status`/`claim` would bypass the CAS + quorum. */
+ private snapshotLead(lead: Lead): Lead {
+ return structuredClone(lead);
+ }
+
+ // ===========================================================================
+ // postLead β de-dup via dedupKey. A duplicate collapses into an ENDORSEMENT
+ // that raises smoke, NEVER a new lead card (HT-1).
+ // ===========================================================================
+ postLead(actor: string, input: LeadInput): Lead {
+ const key =
+ input.dedupKey ||
+ dedupKey({
+ file: input.where.file,
+ cwe: input.cwe,
+ line: input.where.line,
+ vulnClass: input.vulnClass,
+ url: input.where.url,
+ });
+
+ const existingId = this.byDedup.get(key);
+ if (existingId !== undefined) {
+ const existing = this.leads.get(existingId);
+ if (existing) {
+ // Honest duplicate β monotonically upgrade the card with any STRONGER evidence the second
+ // finder brought (never downgrade), then fold into an endorsement (chase-the-smoke). NOTE
+ // (HT-2): a duplicate of a quorum-refuted card is recorded as an endorsement but does NOT
+ // resurface it β a verified refutation is sticky; re-finding it must not re-open it.
+ this.upgradeEvidence(existing, input);
+ return this.endorse(actor, existing.id, `duplicate: ${input.title}`) ?? this.snapshotLead(existing);
+ }
+ }
+
+ const t = this.now();
+ const lead: Lead = {
+ id: randomUUID(),
+ dedupKey: key,
+ kind: input.kind,
+ title: input.title,
+ // Clone on INGEST too (not just on the outbound snapshot): a caller that retains and later mutates
+ // the `where` object it passed in must not be able to re-point the board's internal projection β
+ // that would poison situationReport() coords and desync the frozen dedupKey. Mirrors the board's
+ // no-live-reference invariant on the inbound path.
+ where: { ...input.where },
+ vulnClass: input.vulnClass,
+ confidence: input.confidence,
+ status: 'open',
+ smoke: 1,
+ postedBy: actor,
+ endorsements: [],
+ refutations: [],
+ provenance: input.provenance,
+ version: 0,
+ postedAt: t,
+ updatedAt: t,
+ severity: input.severity,
+ };
+ this.leads.set(lead.id, lead);
+ this.byDedup.set(key, lead.id);
+ this.append('lead:posted', actor, lead.id, { dedupKey: key, kind: lead.kind });
+ this.emit('lead:posted', this.snapshotLead(lead));
+ return this.snapshotLead(lead);
+ }
+
+ // ===========================================================================
+ // claim β a REAL synchronous compare-and-set. There is NO `await` anywhere
+ // between the version read and the write, so on Node's single-threaded loop
+ // this is genuinely atomic: two calls racing the same expectedVersion can only
+ // have ONE win (HT-1). Grants a lease; an already-held live lease blocks.
+ // ===========================================================================
+ claim(
+ leadId: string,
+ agentId: string,
+ expectedVersion: number,
+ leaseMs = 120_000,
+ ): { granted: boolean; version: number } {
+ const lead = this.leads.get(leadId);
+ if (!lead) return { granted: false, version: -1 };
+
+ // A quorum-terminal lead (confirmed / refuted / dead) is NOT claimable β a verified refutation is
+ // sticky (HT-2), so re-claiming can never resurrect a killed lead back into active work.
+ if (lead.status === 'refuted' || lead.status === 'confirmed' || lead.status === 'dead') {
+ this.append('lead:claim-denied', agentId, lead.id, { reason: 'terminal', status: lead.status, version: lead.version });
+ this.emit('lead:claim-denied', { leadId, agentId, version: lead.version });
+ return { granted: false, version: lead.version };
+ }
+
+ // --- BEGIN atomic critical section (no await past this point until write) ---
+ const t = this.now();
+
+ // Someone else holds a live (unexpired) lease β deny, regardless of version.
+ if (lead.claim && lead.claim.leaseUntil > t && lead.claim.by !== agentId) {
+ this.append('lead:claim-denied', agentId, lead.id, { reason: 'held', version: lead.version });
+ this.emit('lead:claim-denied', { leadId, agentId, version: lead.version });
+ return { granted: false, version: lead.version };
+ }
+
+ // Compare-and-set: the caller's view of the lead must be current.
+ if (lead.version !== expectedVersion) {
+ this.append('lead:claim-denied', agentId, lead.id, { reason: 'stale-version', version: lead.version });
+ this.emit('lead:claim-denied', { leadId, agentId, version: lead.version });
+ return { granted: false, version: lead.version };
+ }
+
+ // Win: bump version (invalidates every other racer's expectedVersion), set the lease.
+ lead.version += 1;
+ lead.claim = { by: agentId, leaseUntil: t + leaseMs, version: lead.version };
+ lead.updatedAt = t;
+ this.setStatus(lead, 'claimed', agentId);
+ // --- END atomic critical section ---
+
+ this.append('lead:claimed', agentId, lead.id, { version: lead.version, leaseUntil: lead.claim.leaseUntil });
+ this.emit('lead:claimed', { lead: this.snapshotLead(lead), agentId });
+ return { granted: true, version: lead.version };
+ }
+
+ // ===========================================================================
+ // releaseExpiredClaims β the lease reaper (the same timeout-backstop idea that
+ // reaps wedged dispatches). Frees any lease whose leaseUntil has passed and
+ // returns the lead to `open` so another agent can take it.
+ // ===========================================================================
+ releaseExpiredClaims(now: number = this.now()): string[] {
+ const released: string[] = [];
+ for (const lead of this.leads.values()) {
+ if (lead.claim && lead.claim.leaseUntil <= now) {
+ const holder = lead.claim.by;
+ delete lead.claim;
+ lead.version += 1;
+ lead.updatedAt = now;
+ // only demote back to open if it's still merely claimed (don't clobber confirmed/refuted)
+ if (lead.status === 'claimed') this.setStatus(lead, 'open', holder);
+ this.append('lead:claim-released', holder, lead.id, { reason: 'expired', version: lead.version });
+ this.emit('lead:claim-released', { lead: this.snapshotLead(lead), reason: 'expired' });
+ released.push(lead.id);
+ }
+ }
+ return released;
+ }
+
+ /** Voluntarily release a claim you hold (e.g. work finished / abandoned). No-op if you
+ * don't hold the live lease. Returns the freed lead, or undefined. */
+ releaseClaim(agentId: string, leadId: string): Lead | undefined {
+ const lead = this.leads.get(leadId);
+ if (!lead || !lead.claim || lead.claim.by !== agentId) return undefined;
+ delete lead.claim;
+ lead.version += 1;
+ lead.updatedAt = this.now();
+ if (lead.status === 'claimed') this.setStatus(lead, 'open', agentId);
+ this.append('lead:claim-released', agentId, lead.id, { reason: 'released', version: lead.version });
+ this.emit('lead:claim-released', { lead: this.snapshotLead(lead), reason: 'released' });
+ return this.snapshotLead(lead);
+ }
+
+ // ===========================================================================
+ // endorse β append-only vote that raises smoke (chase-the-smoke priority).
+ // ===========================================================================
+ endorse(actor: string, leadId: string, note?: string): Lead | undefined {
+ const lead = this.leads.get(leadId);
+ if (!lead) return undefined;
+ const vote: LeadVote = { by: actor, note, at: this.now() };
+ lead.endorsements.push(vote);
+ lead.smoke += 1;
+ lead.version += 1;
+ lead.updatedAt = vote.at;
+ this.append('lead:endorsed', actor, lead.id, { smoke: lead.smoke });
+ this.emit('lead:endorsed', this.snapshotLead(lead));
+ return this.snapshotLead(lead);
+ }
+
+ // ===========================================================================
+ // refute β APPEND-ONLY. Records a refutation vote (and optional cited guard) but
+ // NEVER flips status by itself (HT-2). Only the audit quorum (Phase-3), after
+ // guardExistsInSource() verifies the cited guard, may set status to 'refuted'.
+ // A `refute()` here is a signal, not a verdict.
+ // ===========================================================================
+ refute(actor: string, leadId: string, note?: string, guard?: string): Lead | undefined {
+ const lead = this.leads.get(leadId);
+ if (!lead) return undefined;
+ const vote: LeadVote = { by: actor, note, guard, at: this.now() };
+ lead.refutations.push(vote);
+ lead.version += 1;
+ lead.updatedAt = vote.at;
+ // NOTE: status is intentionally NOT changed here. See method doc + HT-2.
+ this.append('lead:refuted', actor, lead.id, { refutations: lead.refutations.length, guard: guard ?? null });
+ this.emit('lead:refuted', this.snapshotLead(lead));
+ return this.snapshotLead(lead);
+ }
+
+ // ===========================================================================
+ // heartbeat β per-agent liveness projection.
+ // ===========================================================================
+ heartbeat(agentId: string, activity: AgentActivity, note?: string): AgentStatus {
+ const status: AgentStatus = { agentId, activity, note, lastSeen: this.now() };
+ this.agents.set(agentId, status);
+ this.append('agent:heartbeat', agentId, undefined, { activity });
+ this.emit('agent:heartbeat', status);
+ return status;
+ }
+
+ // ---------------------------------------------------------------------------
+ // internal: status transition (the ONLY path that changes Lead.status).
+ // The board only ever drives open β claimed. confirmed/refuted/dead are set by
+ // the quorum through `setLeadStatus`, kept separate so the board never flips a
+ // status on agreement alone.
+ // ---------------------------------------------------------------------------
+ private setStatus(lead: Lead, to: Lead['status'], actor: string): void {
+ if (lead.status === to) return;
+ const from = lead.status;
+ lead.status = to;
+ lead.updatedAt = this.now();
+ this.append('lead:status-changed', actor, lead.id, { from, to });
+ this.emit('lead:status-changed', { lead: this.snapshotLead(lead), from, to });
+ }
+
+ /**
+ * Quorum-only status setter (Phase-3 hook). The audit coordinator calls this to promote a
+ * card to `confirmed` (after strict-majority + independent reproduction + gate) or `refuted`
+ * (after guardExistsInSource() verified a cited guard) or retire it as `dead`. It is NOT
+ * called by `endorse()`/`refute()` β those only append votes.
+ */
+ setLeadStatus(actor: string, leadId: string, to: Lead['status']): Lead | undefined {
+ const lead = this.leads.get(leadId);
+ if (!lead) return undefined;
+ lead.version += 1;
+ this.setStatus(lead, to, actor);
+ return this.snapshotLead(lead);
+ }
+
+ // ===========================================================================
+ // situationReport β a BOUNDED string threaded into an agent's prompt at task
+ // start. It NEVER dumps the raw board: top-K unclaimed leads by smoke, YOUR own
+ // claims, refuted-guard keys to avoid, and the live agents β hard-capped to
+ // cfg.reportCharCap chars. Mirrors cli-swarm's renderBoard four-section layout.
+ // ===========================================================================
+ situationReport(forAgentId: string, opts: { maxLeads?: number } = {}): string {
+ const cap = this.cfg.reportCharCap;
+ const maxLeads = Math.max(0, opts.maxLeads ?? this.cfg.defaultMaxLeads);
+ const now = this.now();
+ const all = Array.from(this.leads.values());
+
+ // ββ top-K UNCLAIMED open leads, ranked by smoke (chase-the-smoke), then recency ββ
+ const unclaimed = all
+ .filter((l) => l.status === 'open' && !(l.claim && l.claim.leaseUntil > now))
+ .sort((a, b) => b.smoke - a.smoke || b.updatedAt - a.updatedAt)
+ .slice(0, maxLeads);
+
+ // ββ the leads THIS agent currently holds a live lease on ββ
+ const mine = all.filter((l) => l.claim && l.claim.by === forAgentId && l.claim.leaseUntil > now);
+
+ // ββ refuted-guard keys to steer clear of (leads the quorum killed) ββ
+ const refuted = all.filter((l) => l.status === 'refuted');
+
+ // ββ live agents (fresh heartbeat within the liveness window) ββ
+ const live = Array.from(this.agents.values())
+ .filter((a) => a.activity !== 'gone' && now - a.lastSeen <= this.cfg.livenessWindowMs)
+ .sort((a, b) => b.lastSeen - a.lastSeen);
+
+ const lines: string[] = [];
+ lines.push(`PACK BOARD β situation for ${forAgentId} (${all.length} lead(s) total)`);
+
+ if (unclaimed.length) {
+ lines.push('OPEN LEADS β unclaimed, hottest first (claim before you dig; don\'t re-tread):');
+ for (const l of unclaimed) {
+ lines.push(` ? [smoke ${l.smoke}/${l.confidence}/${l.vulnClass}] ${this.coords(l)} β ${clip(l.title, 90)} (v${l.version})`);
+ }
+ }
+ if (mine.length) {
+ lines.push('YOUR CLAIMS (you hold the lease β finish or release):');
+ for (const l of mine) {
+ lines.push(` β ${this.coords(l)} β ${clip(l.title, 90)} (leaseβ${l.claim?.leaseUntil ?? 0})`);
+ }
+ }
+ if (refuted.length) {
+ lines.push('REFUTED / CLEARED (quorum killed these β do NOT resurface):');
+ for (const l of refuted.slice(0, maxLeads)) {
+ const g = l.refutations.find((r) => r.guard)?.guard;
+ lines.push(` β ${this.coords(l)} β ${clip(l.title, 70)}${g ? ` (guard: ${clip(g, 60)})` : ''}`);
+ }
+ }
+ if (live.length) {
+ lines.push(`LIVE AGENTS: ${live.map((a) => `${a.agentId}:${a.activity}`).join(', ')}`);
+ }
+
+ return boundedJoin(lines, cap);
+ }
+
+ // ---------------------------------------------------------------------------
+ // display coords: prefer file:line, fall back to url, then the vault FK.
+ // ---------------------------------------------------------------------------
+ private coords(l: Lead): string {
+ if (l.where.file) return `${l.where.file}:${l.where.line ?? '?'}`;
+ if (l.where.url) return l.where.url;
+ if (l.where.targetId) return `finding:${l.where.targetId}`;
+ return l.dedupKey;
+ }
+
+ // ===========================================================================
+ // read-only projection accessors (for the controller / tests / SSE).
+ // ===========================================================================
+
+ /** The append-only log (defensive DEEP copy β callers cannot mutate history or entry payloads). */
+ getLog(): BoardEvent[] {
+ return this.log.map((e) => structuredClone(e));
+ }
+
+ getLead(leadId: string): Lead | undefined {
+ const lead = this.leads.get(leadId);
+ return lead ? this.snapshotLead(lead) : undefined;
+ }
+
+ getAllLeads(): Lead[] {
+ return Array.from(this.leads.values(), (l) => this.snapshotLead(l));
+ }
+
+ /** Open, currently-unclaimed leads (what a fresh agent can pick up). */
+ getOpenLeads(now: number = this.now()): Lead[] {
+ return this.getAllLeads().filter((l) => l.status === 'open' && !(l.claim && l.claim.leaseUntil > now));
+ }
+
+ getAgents(): AgentStatus[] {
+ return Array.from(this.agents.values(), (a) => ({ ...a }));
+ }
+
+ /** Agents whose last heartbeat is within the liveness window. */
+ getLiveAgents(now: number = this.now()): AgentStatus[] {
+ return this.getAgents().filter((a) => a.activity !== 'gone' && now - a.lastSeen <= this.cfg.livenessWindowMs);
+ }
+}
+
+// =============================================================================
+// helpers β pure string bounding
+// =============================================================================
+
+/** Clip a string to `n` chars with an ellipsis, so no single field blows the budget. */
+function clip(s: string, n: number): string {
+ const str = String(s ?? '');
+ return str.length <= n ? str : `${str.slice(0, Math.max(0, n - 1))}β¦`;
+}
+
+/**
+ * Join lines with '\n' but NEVER exceed `cap` chars. If the full join fits, return it.
+ * Otherwise take as many whole leading lines as fit, and append a truncation marker that
+ * itself is accounted for in the budget β the returned string is guaranteed <= cap.
+ */
+function boundedJoin(lines: string[], cap: number): string {
+ const full = lines.join('\n');
+ if (full.length <= cap) return full;
+
+ const marker = '\nβ¦(truncated)';
+ const budget = Math.max(0, cap - marker.length);
+ const out: string[] = [];
+ let used = 0;
+ for (const line of lines) {
+ const add = out.length === 0 ? line.length : line.length + 1; // +1 for the '\n'
+ if (used + add > budget) break;
+ out.push(line);
+ used += add;
+ }
+ const joined = out.join('\n') + marker;
+ // Final belt-and-suspenders: hard-slice in the pathological case of a single over-long line.
+ return joined.length <= cap ? joined : joined.slice(0, cap);
+}
diff --git a/src/pack/health.ts b/src/pack/health.ts
new file mode 100644
index 0000000..cc3ecfa
--- /dev/null
+++ b/src/pack/health.ts
@@ -0,0 +1,183 @@
+// =============================================================================
+// RATE-LIMIT / DEGRADED-HEALTH + LLM SEMAPHORE β Phase-0 pack-hunt safety primitive (HT-3)
+// =============================================================================
+// The exact cli-swarm lesson, ported into the engine path: N keyless local-agent operators share
+// ONE subscription quota. Under a pack hunt they trip usage limits; several calls come back empty.
+// If an empty/errored slot reads as "clean / found-nothing", the pack calls a rate-limited target
+// CONVERGED. That is the failure this module exists to make impossible.
+//
+// Three pure, dependency-free primitives (no engine imports β safe to share everywhere):
+// 1. isRateLimited(text) β detect 429 / quota / usage-limit / rate-limit signatures.
+// 2. DegradedTracker β records ok/fail per local-agent call; a run where failures DOMINATE
+// (>= 50%) verdicts INVALID_DEGRADED, NEVER a clean assessment.
+// 3. Semaphore β a correct bounded-concurrency async gate. A SINGLE GLOBAL instance
+// (localAgentSemaphore) caps ALL local-agent calls across the pack, so
+// 8 operators behind one quota don't self-DoS into rate-limit hell.
+//
+// Ported verbatim from scripts/cli-swarm.mjs:
+// - the isRateLimited regex (cli-swarm.mjs:54-56).
+// - the degraded rule `backendFails / backendCalls >= 0.5 => INVALID_DEGRADED` (cli-swarm.mjs:355-358).
+// =============================================================================
+
+/**
+ * True when `text` carries a rate-limit / quota-exhaustion / usage-limit / overload signature. A
+ * local-agent call that hits its subscription quota returns an ERROR STRING, not findings β surface
+ * it so the slot is flagged degraded and can NEVER be mistaken for a clean "found nothing".
+ *
+ * Broadened past cli-swarm's original (which missed Anthropic 529/"overloaded", Bedrock
+ * "TooManyRequestsException", gRPC/Vertex "RESOURCE_EXHAUSTED", bare "throttled", and reversed
+ * "exceeded your quota" phrasings) so a throttled reply can't slip through as a clean result. The
+ * fail-safe direction is to OVER-flag degraded β a false positive just re-runs a slot; a false
+ * negative books a rate-limited body as a clean assessment, the exact bug this guards.
+ *
+ * Word/phrase signatures fire anywhere; the numeric HTTP status codes (429/503/529) fire ONLY when
+ * they read as a status/error, so a bare integer in benign output ("line 429", "CWE-529",
+ * "4290 findings") does not false-positive.
+ *
+ * Signatures are provider ERROR-ENVELOPE tokens, NOT the bare English a security finding uses. In a
+ * pack hunt the local-agent's own result body legitimately describes rate-limit / DoS vulnerabilities
+ * ("rate limiting is MISSING on /login", "no rate-limit on password reset", "CWE-400 β¦ service
+ * unavailable", "slow down attacker", "concurrent request bug") β those must read as FINDINGS, not as a
+ * throttled slot (adversarial review, 7th pass). So the ambiguous bare phrases were dropped: a limit
+ * being HIT is matched ("rate limit exceeded/reached/hit", past-tense "rate-limited"), while a finding
+ * that a limit is ABSENT is not. Genuine 503/"service unavailable" throttles are still caught via the
+ * numeric HTTP-status regex.
+ */
+const RATE_LIMIT_SIGNATURES =
+ /too[ _]?many[ _]?requests|throttl(?:ed|ing)|resource_exhausted|resource has been exhausted|overloaded(?!\s+(?:operator|function|method))|insufficient_quota|rate_limit_error|quota[ _]?exceeded|exceeded your (?:current )?quota|secondary rate limit|retry[ _-]?after|rate[ _-]?limited\b|(?:hit|reached|exceeded)\s+(?:your |the )?(?:usage|message)\s+limit|(?:usage|message)\s+limit\s+(?:reached|exceeded|hit)|rate[ _-]?limit(?:s|ing)?\s+(?:exceeded|reached|hit)|(?:exceed(?:ed)?|reach(?:ed)?|hit|would exceed|will exceed)\s+(?:the |your )?(?:[a-z'β-]+\s+)*rate[ _-]?limit/i;
+const RATE_LIMIT_HTTP_STATUS =
+ /(?:\bhttp\b|status|code|error)[\s:=/_-]*(?:429|503|529)\b|\b(?:429|503|529)\b\s*[:-]?\s*(?:too many|over[ _-]?load|service unavailable|rate|throttl|slow down)/i;
+
+export function isRateLimited(text: string): boolean {
+ const s = String(text ?? '');
+ return RATE_LIMIT_SIGNATURES.test(s) || RATE_LIMIT_HTTP_STATUS.test(s);
+}
+
+/** Health verdict for a batch of local-agent calls. Mirrors cli-swarm's verdict vocabulary. */
+export type DegradedVerdict = 'OK' | 'INVALID_DEGRADED';
+
+/** Failures must DOMINATE for a run to be invalid: a few lost calls = partial coverage, not invalid. */
+export const DEGRADED_THRESHOLD = 0.5;
+
+/**
+ * Accounting for a batch of local-agent (LLM backbone) calls. `record(ok)` per call; `verdict()` is
+ * INVALID_DEGRADED once the failure ratio reaches DEGRADED_THRESHOLD β i.e. the assessment didn't
+ * meaningfully run, so it must NOT read as "clean / secure". A run with zero calls is INVALID_DEGRADED
+ * (nothing actually executed); a run with only successes is OK.
+ *
+ * Rule ported from scripts/cli-swarm.mjs:355 β `degraded = backendCalls > 0 && backendFails/backendCalls >= 0.5`.
+ * (This tracker additionally treats the zero-call case as degraded: an engine slot that never ran is
+ * not a clean result either.)
+ */
+export class DegradedTracker {
+ /** total local-agent calls attempted */
+ calls = 0;
+ /** calls that failed (error, timeout, empty, or rate-limited) */
+ fails = 0;
+
+ /** Record one call outcome. `ok=false` for any error / timeout / empty / rate-limited result. */
+ record(ok: boolean): void {
+ this.calls += 1;
+ if (!ok) this.fails += 1;
+ }
+
+ /**
+ * Convenience: record a raw result string. A rate-limited body counts as a FAILURE even if the
+ * call technically "returned", so a quota banner can never be booked as a good call.
+ */
+ recordResult(ok: boolean, text?: string): void {
+ this.record(ok && !(text !== undefined && isRateLimited(text)));
+ }
+
+ /** Ratio of failed calls in [0,1]; 0 when no calls have been recorded. */
+ failRatio(): number {
+ return this.calls > 0 ? this.fails / this.calls : 0;
+ }
+
+ /**
+ * INVALID_DEGRADED when failures dominate (>= DEGRADED_THRESHOLD of calls) OR nothing ran at all;
+ * OK otherwise. A rate-limited slot must NEVER surface here as a clean / found-nothing verdict.
+ */
+ verdict(): DegradedVerdict {
+ if (this.calls === 0) return 'INVALID_DEGRADED';
+ return this.failRatio() >= DEGRADED_THRESHOLD ? 'INVALID_DEGRADED' : 'OK';
+ }
+}
+
+/**
+ * A correct bounded-concurrency async semaphore. `acquire()` resolves once a slot is free and hands
+ * back a release function; call it exactly once when the guarded work finishes. Waiters are served
+ * strictly FIFO. Never lets more than `max` holders run at once.
+ *
+ * A SINGLE GLOBAL instance (localAgentSemaphore) is meant to wrap every local-agent call across the
+ * pack so N keyless operators on one shared quota don't stampede into rate-limits. Node's
+ * single-threaded loop makes the counter mutations here genuinely atomic (no yield between check and
+ * decrement in the acquire fast-path).
+ */
+export class Semaphore {
+ private readonly max: number;
+ private active = 0;
+ private readonly waiters: Array<() => void> = [];
+
+ constructor(max: number) {
+ if (!Number.isFinite(max) || max < 1) {
+ throw new RangeError(`Semaphore max must be a finite integer >= 1 (got ${max})`);
+ }
+ this.max = Math.floor(max);
+ }
+
+ /** Number of holders currently inside the guarded section (for tests / introspection). */
+ get inFlight(): number {
+ return this.active;
+ }
+
+ /** Number of callers currently blocked in acquire() waiting for a slot. */
+ get queued(): number {
+ return this.waiters.length;
+ }
+
+ /**
+ * Acquire a slot. Resolves with an idempotent release fn β calling it more than once is a no-op,
+ * so a double-release can't inflate the pool past `max`.
+ */
+ acquire(): Promise<() => void> {
+ return new Promise<() => void>((resolve) => {
+ const grant = (): void => {
+ this.active += 1;
+ let released = false;
+ resolve(() => {
+ if (released) return; // idempotent: guard against double-release
+ released = true;
+ this.active -= 1;
+ const next = this.waiters.shift();
+ if (next) next();
+ });
+ };
+ if (this.active < this.max) grant();
+ else this.waiters.push(grant);
+ });
+ }
+
+ /** Run `fn` while holding a slot; always releases, even if `fn` throws. */
+ async run(fn: () => Promise): Promise {
+ const release = await this.acquire();
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+ }
+}
+
+/**
+ * Global concurrency cap over ALL local-agent (LLM backbone) calls across a pack hunt. One shared
+ * subscription quota => a single global semaphore, NOT one per operator (per HT-3). Bound from
+ * T3MP3ST_LLM_CONCURRENCY (default 2). Wrap every local-agent dispatch in
+ * `localAgentSemaphore.run(() => localAgentChat(...))`.
+ */
+function resolveConcurrency(): number {
+ const raw = parseInt((process.env.T3MP3ST_LLM_CONCURRENCY || '').trim(), 10);
+ return Number.isFinite(raw) && raw >= 1 ? raw : 2;
+}
+
+export const localAgentSemaphore = new Semaphore(resolveConcurrency());
diff --git a/src/pack/types.ts b/src/pack/types.ts
new file mode 100644
index 0000000..07a4cfc
--- /dev/null
+++ b/src/pack/types.ts
@@ -0,0 +1,175 @@
+/**
+ * T3MP3ST Pack Hunt β shared-workspace types (Phase-2)
+ *
+ * The Pack Hunt is N specialized agents exploring ONE target in parallel, sharing a
+ * live workspace (this board), posting leads, and claiming work so they don't re-tread.
+ * See the pack-hunt design ("Shared workspace / lead-board" +
+ * "Lead & work coordination").
+ *
+ * This file is the DATA layer only β pure types, no behavior. The `PackBoard` in
+ * `./board.js` is an append-only event log that folds these into projections.
+ *
+ * Design constraints these types encode (all load-bearing per the red-team, Β§2):
+ * - A `Lead` is a lightweight *card*, NOT a vault Finding. It carries a foreign key
+ * (`where.targetId`) back to the provenance-gated `Finding.id` when one exists; the
+ * board never launders provenance itself (HT-1). `provenance` here is a plain flag.
+ * - `version` + `claim` exist so `claim()` can be a REAL synchronous compare-and-set
+ * (HT-1: "the claim-lock is advisory" β make it atomic).
+ * - `refutations` is append-only and NEVER unilaterally flips `status` (HT-2: a false
+ * refute must not permanently block re-finding a real bug).
+ */
+
+import type { Severity } from '../types/index.js';
+
+// =============================================================================
+// LEAD β the unit of shared work on the board
+// =============================================================================
+
+/** Where a lead points. Mirrors cli-swarm's `{file, line}` coordinates and adds the
+ * optional foreign keys the design calls for (a URL surface, or the vault `Finding.id`
+ * this card mirrors). Every field is optional β a recon lead may have only a `url`. */
+export interface LeadWhere {
+ /** Repo-relative source path (cli-swarm's `file`). */
+ file?: string;
+ /** 1-indexed line (cli-swarm's `line`; bucketed into the dedupKey). */
+ line?: number;
+ /** A networked surface (endpoint / host) when the lead isn't source-anchored. */
+ url?: string;
+ /** Foreign key back to the vault's provenance-gated `Finding.id`, once one exists.
+ * The board mirrors finding *cards*; it does not own or gate the finding itself. */
+ targetId?: string;
+}
+
+/** Lifecycle of a lead. Mirrors cli-swarm's `open β claimed β confirmed/refuted` and
+ * adds `dead` for leases/leads the reaper or a quorum retires.
+ *
+ * IMPORTANT: `refuted` is only reachable through the audit quorum (Phase-3), never by a
+ * single `refute()` call β see PackBoard.refute (HT-2). `refute()` only appends a vote. */
+export type LeadStatus = 'open' | 'claimed' | 'confirmed' | 'refuted' | 'dead';
+
+/** How the lead's backing evidence was produced β the same three-way flag the honesty
+ * gate keys on (`Finding.verifyGate.provenance`: 'none' | 'context' | 'tool'). A card is
+ * *not trusted* until a real reproduction upgrades it; this is a plain label, not a gate. */
+export type LeadProvenance = 'tool' | 'context' | 'none';
+
+/** A lease-based soft lock on a lead. Held by exactly one agent at a time; reaped by
+ * `releaseExpiredClaims(now)` when `leaseUntil` passes (the same timeout-backstop idea
+ * that reaps wedged dispatches). `version` is the CAS token claim() compared against. */
+export interface LeadClaim {
+ /** Agent id currently holding the lease. */
+ by: string;
+ /** Epoch-ms after which the lease is stale and reclaimable. */
+ leaseUntil: number;
+ /** The lead `version` at the moment the claim was granted (for observability/debugging). */
+ version: number;
+}
+
+/** An endorsement or refutation vote. Append-only; never mutates or deletes prior votes. */
+export interface LeadVote {
+ /** Agent id casting the vote. */
+ by: string;
+ /** Free-text rationale (an endorsement note, or a claimed "killing guard" for a refute). */
+ note?: string;
+ /** For a refutation: the cited guard `file:line`. The quorum (Phase-3) MUST verify this
+ * with `guardExistsInSource()`; a cited-but-absent guard downgrades the vote (HT-2). */
+ guard?: string;
+ /** When the vote was cast (epoch ms). */
+ at: number;
+}
+
+/**
+ * A lead card on the shared board. A `kind:'work'` lead is a claimed sub-surface / task
+ * ("I own the auth module"); a `kind:'lead'` is a suspected vuln. Both live in the same
+ * map so claim/endorse/refute work uniformly.
+ */
+export interface Lead {
+ /** Stable board id (randomUUID). NOT the vault Finding.id β see `where.targetId`. */
+ id: string;
+ /** Collision key for de-dup: file::cwe::line-bucket (cli-swarm's `dedupKey`). Two posts
+ * with the same key are the SAME lead β the second collapses into an endorsement. */
+ dedupKey: string;
+ /** A suspected vulnerability (`lead`) vs a claimed piece of work / sub-surface (`work`). */
+ kind: 'lead' | 'work';
+ /** One-line human title. */
+ title: string;
+ /** Where the lead points (source coords / url / vault FK). */
+ where: LeadWhere;
+ /** Specialist vuln-class key β cli-swarm's LENSES vocabulary (e.g. 'injection',
+ * 'path-traversal', 'ssrf-redirect'), or a free key for work items. */
+ vulnClass: string;
+ /** Poster's self-reported confidence. Advisory only; never promotes a card by itself. */
+ confidence: 'high' | 'medium' | 'low';
+ /** Lifecycle state. Only the quorum flips to confirmed/refuted; the board never does. */
+ status: LeadStatus;
+ /** "Chase-the-smoke" priority: rises each time a duplicate collapses into an endorsement,
+ * and with each independent endorsement. The situation report ranks unclaimed leads by it. */
+ smoke: number;
+ /** Agent id that first posted the lead. */
+ postedBy: string;
+ /** Append-only endorsement votes (duplicates fold in here, raising `smoke`). */
+ endorsements: LeadVote[];
+ /** Append-only refutation votes. Does NOT flip status on its own (HT-2). */
+ refutations: LeadVote[];
+ /** Present iff currently claimed (a live, unexpired lease). */
+ claim?: LeadClaim;
+ /** Provenance flag of the backing evidence. A card is untrusted until reproduced. */
+ provenance: LeadProvenance;
+ /** Monotonic optimistic-concurrency counter. Bumped on every state change; the CAS token. */
+ version: number;
+ /** When the card first appeared (epoch ms). */
+ postedAt: number;
+ /** Last time any projection-visible field changed (epoch ms). */
+ updatedAt: number;
+ /** Optional severity hint carried from the poster (advisory; the vault owns the real one). */
+ severity?: Severity;
+}
+
+// =============================================================================
+// BOARD EVENTS β the append-only log's record type
+// =============================================================================
+
+/** One entry in the board's append-only event log. Every mutation emits exactly one of
+ * these (also re-emitted on the EventEmitter as `board:event`), so the full history is
+ * replayable and the projections are a pure fold over `type`. */
+export interface BoardEvent {
+ /** Unique event id (randomUUID). */
+ id: string;
+ /** What happened. */
+ type:
+ | 'lead:posted'
+ | 'lead:endorsed'
+ | 'lead:refuted'
+ | 'lead:claimed'
+ | 'lead:claim-denied'
+ | 'lead:claim-released'
+ | 'lead:status-changed'
+ | 'agent:heartbeat';
+ /** Agent id that caused the event (the actor). */
+ actor: string;
+ /** The lead this event is about, when applicable. */
+ leadId?: string;
+ /** Small, structured extra data (dedupKey collapsed-into, oldβnew status, etc.). */
+ data?: Record;
+ /** Epoch-ms sequence timestamp (the log is ordered by append, this is for display). */
+ at: number;
+}
+
+// =============================================================================
+// AGENT STATUS β per-agent heartbeat projection
+// =============================================================================
+
+/** What an agent is doing right now. Used to know which agents are live (for the situation
+ * report's "live agents" line and, in Phase-3, for electing an idle audit panel). */
+export type AgentActivity = 'idle' | 'hunting' | 'auditing' | 'reporting' | 'gone';
+
+/** A per-agent heartbeat, folded from the latest `agent:heartbeat` event for that agent. */
+export interface AgentStatus {
+ /** Agent id. */
+ agentId: string;
+ /** Current activity. `gone` marks an agent past its liveness window. */
+ activity: AgentActivity;
+ /** Optional one-line note ("owning auth module", "refuting lead X"). */
+ note?: string;
+ /** Epoch-ms of the last heartbeat β liveness is derived from this + a staleness window. */
+ lastSeen: number;
+}
diff --git a/src/redact.ts b/src/redact.ts
new file mode 100644
index 0000000..7264118
--- /dev/null
+++ b/src/redact.ts
@@ -0,0 +1,69 @@
+/**
+ * Secret / credential redaction for anything the server serves back to a client β ledgers, the
+ * arsenal-approval audit, SSE events, and persisted state snapshots. Extracted from server.ts so this
+ * load-bearing safety boundary is a PURE, dependency-free module that can be unit-tested in isolation
+ * (importing server.ts would start the HTTP listener + LLM). server.ts imports these back unchanged.
+ */
+
+/** Provider secret signatures. Each is applied globally; a match is replaced with `[redacted]`. */
+export const SECRET_PATTERNS: Record = {
+ aws_access_key: { pattern: /AKIA[0-9A-Z]{16}/g, severity: 'critical', provider: 'AWS' },
+ aws_secret_key: { pattern: /[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, severity: 'critical', provider: 'AWS' },
+ gcp_api_key: { pattern: /AIza[0-9A-Za-z\-_]{35}/g, severity: 'high', provider: 'GCP' },
+ github_token: { pattern: /ghp_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
+ github_oauth: { pattern: /gho_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
+ anthropic_api_key: { pattern: /sk-ant-api\d{2}-[A-Za-z0-9_\-]{16,}/g, severity: 'critical', provider: 'Anthropic' },
+ openai_api_key: { pattern: /sk-[A-Za-z0-9_\-]{20,}/g, severity: 'critical', provider: 'OpenAI' },
+ gitlab_token: { pattern: /glpat-[A-Za-z0-9\-_]{20}/g, severity: 'critical', provider: 'GitLab' },
+ jwt: { pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: 'high', provider: 'JWT' },
+ stripe_live: { pattern: /sk_live_[0-9a-zA-Z]{24}/g, severity: 'critical', provider: 'Stripe' },
+ slack_token: { pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/g, severity: 'high', provider: 'Slack' },
+ postgres_uri: { pattern: /postgres(ql)?:\/\/[^:]+:[^@]+@[^/]+\/\w+/gi, severity: 'critical', provider: 'PostgreSQL' },
+ mongodb_uri: { pattern: /mongodb(\+srv)?:\/\/[^:]+:[^@]+@[^/]+/gi, severity: 'critical', provider: 'MongoDB' },
+ rsa_private: { pattern: new RegExp('-----BEGIN RSA ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'RSA' },
+ openssh_private: { pattern: new RegExp('-----BEGIN OPENSSH ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'OpenSSH' },
+ password_field: { pattern: /password["\s]*[:=]["\s]*[^"'\s]{4,}/gi, severity: 'high', provider: 'Generic' },
+ api_key_field: { pattern: /api[_-]?key["\s]*[:=]["\s]*["']?[A-Za-z0-9\-_]{16,}["']?/gi, severity: 'high', provider: 'Generic' },
+};
+
+export function redactString(value: string): string {
+ let redacted = value;
+ for (const { pattern } of Object.values(SECRET_PATTERNS)) {
+ pattern.lastIndex = 0;
+ redacted = redacted.replace(pattern, '[redacted]');
+ }
+ return redacted
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, 'Bearer [redacted]')
+ .replace(/(api[_-]?key|token|secret|password)=([^&\s]+)/gi, '$1=[redacted]')
+ // Generic URL userinfo (basic-auth): `scheme://user:pass@host` β the SECRET_PATTERNS only cover
+ // postgres/mongodb URIs, so an http(s)/ssh/ftp login URL supplied to a gated tool would otherwise
+ // carry its password verbatim into the arsenal-approval audit's target/action (served over both the
+ // REST endpoint and the SSE feed). Scrub the whole userinfo up to the LAST `@` before the host, so a
+ // password that itself contains `@` is fully covered; credential-free URLs are left untouched.
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)(?:[^/\s@]+@)+/gi, '$1[redacted]@');
+}
+
+export function redactLedgerText(value: string, limit = 4000): string {
+ const redacted = redactString(value);
+ if (redacted.length <= limit) return redacted;
+ return `${redacted.slice(0, limit)}...[truncated ${redacted.length - limit} chars]`;
+}
+
+export function redactSecrets(value: unknown, seen = new WeakSet