feat: pack-hunt safety + opt-in Kali+ arsenal + capability-approval gate + tester-feedback UX

Adds three coordinated capabilities and tightens two defaults on purpose — this is
safety hardening, NOT a backward-compatible drop-in (see "Tightened defaults").
Fifteen independent adversarial review passes; findings fixed and test-pinned.

- Pack-hunt safety: a strict-majority adjudicator with a comment/string/regex/
  raw-string/heredoc/POD-aware cite-check (language-gated across C/C++/Py/Rust/Go/
  Java/JS/TS/shell/SQL/PHP/Ruby/Perl) so a hallucinated killing-guard can't refute
  a real finding — the cited comparison's operator must match source, and a
  string/regex/heredoc/generic-arglist/template-interpolation is blanked before
  matching. Plus a rate-limit/degraded-health tracker, a global LLM semaphore, and
  a PackBoard with an atomic claim CAS + snapshot-on-emit/clone-on-ingest.
- Opt-in Kali+ arsenal (T3MP3ST_FULL_ARSENAL, OFF by default): 35 default tools
  (31 built-in + 4 external-CLI wrappers) -> 83 with the catalog armed (verify-claims
  CLAIM 4). Most adapters need the underlying binary installed. metasploit / hydra /
  bloodhound are never minted through the generic factory.
- Capability-approval + spicy-action gate for the NEW dangerous arsenal only
  (pre-existing built-ins keep running freely): approve-once, fail-safe deny when
  unattended, redacted audit trail over SSE. T3MP3ST_GATE_BUILTINS=1 extends it to
  the spicy built-in probes. Audit redaction is a pure, unit-tested module.

Tightened defaults (intentional, ON by default — NOT backward-compatible):
- Egress containment: once a mission target is set, built-in networked tools refuse
  off-scope public hosts (not the target/subdomains, not loopback/private) with
  SCOPE DENIED — keeps the agent on authorized scope.
- Role-specialized operator toolkits: each archetype gets a curated default tool
  menu (scanner -> scan tools, exploiter -> sqli/xss/etc). Tools stay registered
  team-wide; the per-role menu is the operator's working set.

UX (from live-tester feedback): a one-click "Approve & run" modal when a standard
Hunt blocks on an external-target receipt (was a dead-end); bounded-parallel
benchmark file ingestion; the deterministic mission-gate chip co-located with the
subjective SITREP pace narration (no more "behind schedule" next to a clean gate);
and click-through finding detail + evidence in the UI (was export-only).

Docs: README + INTEGRITY_LEDGER made re-derivable — honest 35/83 tool count, the
egress-containment default documented, and the Cybench number reconciled to the
single verify-claims-enforced 23/40 = 58% (dropped the stale/uncited 21 and 26).
README voice pass: killed the underselling `Successful Exploits: 0` disqualifier
(read as "never popped anything" — false, given CVE-Zero + the live disclosure
pipeline); swagger now rests on receipts, not vibes. Also corrected the operator
status — the downstream operators run the SAME real tool-backed ReAct loop as
recon (not "orchestration stubs"); the honest limit is that the coordinated swarm
is unproven (single-agent is the benchmarked path), not that they're scaffolding.

Gates: 292 tests, tsc + lint clean, verify-claims 20/20, arsenal:smoke 125/0,
disclosure-CLI self-test 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
t3mp3st contributorsandClaude Opus 4.8 committed 2026-07-04 09:38:23 -04:00
1 parent 5255000cb6
commit 06ffe3ef66
35 files changed
+5019 -211

No files matched your search

+548
View File
@@ -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<PackBoardEvents> {
/** 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<string, Lead> = new Map();
/** Fast index dedupKey → leadId, so a duplicate post collapses in O(1). */
private readonly byDedup: Map<string, string> = new Map();
/** Folded projection: latest heartbeat per agent. */
private readonly agents: Map<string, AgentStatus> = new Map();
private readonly cfg: PackBoardConfig;
/** Injected clock — defaults to Date.now, overridable so tests are deterministic. */
private readonly now: () => number;
constructor(config: Partial<PackBoardConfig> = {}, 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<string, unknown>,
): 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<Severity, number> = { 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);
}
+183
View File
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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());
+175
View File
@@ -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<string, unknown>;
/** 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;
}