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:
1 parent
5255000cb6
commit
06ffe3ef66
35 files changed
+5019
-211
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user