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,193 @@
|
||||
/**
|
||||
* Kali+ adapter → CustomTool factory (Phase-1).
|
||||
* Pins the load-bearing safety + behavior contract:
|
||||
* - catalog_only / import_only adapters are NEVER minted (metasploit/hydra/bloodhound stay off the
|
||||
* callable surface) — the factory returns null.
|
||||
* - a missing binary DEGRADES (returns {success:false, error:<installHint>}) instead of throwing.
|
||||
* - an out-of-scope target is refused (SCOPE DENIED) BEFORE the subprocess runs.
|
||||
* - a mintable adapter produces a CustomTool with a working name + category + real stdout output.
|
||||
* - buildAdapterTools drops non-mintable adapters and skips already-registered tool names.
|
||||
* All deps are injected fakes — no real binaries are spawned.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
adapterToCustomTool,
|
||||
buildAdapterTools,
|
||||
toolNameFor,
|
||||
type AdapterToolDeps,
|
||||
type SubprocessResult,
|
||||
} from '../arsenal/adapter-tools.js';
|
||||
import { TOOL_ADAPTERS } from '../arsenal/catalog.js';
|
||||
import type { ToolAdapter } from '../arsenal/catalog.js';
|
||||
import type { CustomTool, ToolContext } from '../types/index.js';
|
||||
|
||||
function adapter(id: string): ToolAdapter {
|
||||
const a = TOOL_ADAPTERS.find(x => x.id === id);
|
||||
if (!a) throw new Error(`test fixture: adapter '${id}' not found in catalog`);
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Mint an adapter and assert (for the type-checker and the test) that it was not gated to null. */
|
||||
function mint(id: string, deps: AdapterToolDeps): CustomTool {
|
||||
const tool = adapterToCustomTool(adapter(id), deps);
|
||||
expect(tool, `${id} should be mintable`).not.toBeNull();
|
||||
return tool as CustomTool;
|
||||
}
|
||||
|
||||
/** A deps double that records subprocess spawns and lets each behavior be configured per test. */
|
||||
function makeDeps(overrides: Partial<AdapterToolDeps> = {}): AdapterToolDeps & { spawns: string[][] } {
|
||||
const spawns: string[][] = [];
|
||||
return {
|
||||
spawns,
|
||||
isToolAvailable: async () => true,
|
||||
runSubprocess: async (_command, args): Promise<SubprocessResult> => {
|
||||
spawns.push(args);
|
||||
return { stdout: 'FAKE_OUTPUT', stderr: '', exitCode: 0 };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ctx = (parameters: Record<string, unknown>): ToolContext => ({ parameters });
|
||||
|
||||
describe('adapterToCustomTool — mint gate', () => {
|
||||
it('NEVER mints catalog_only / import_only adapters (metasploit, hydra, bloodhound → null)', () => {
|
||||
const deps = makeDeps();
|
||||
expect(adapterToCustomTool(adapter('metasploit'), deps)).toBeNull(); // catalog_only
|
||||
expect(adapterToCustomTool(adapter('hydra'), deps)).toBeNull(); // catalog_only
|
||||
expect(adapterToCustomTool(adapter('bloodhound'), deps)).toBeNull(); // import_only
|
||||
});
|
||||
|
||||
it('mints a command-ready adapter with a working name + category', () => {
|
||||
const tool = mint('nmap', makeDeps());
|
||||
expect(tool.name).toBe(toolNameFor(adapter('nmap')));
|
||||
expect(tool.name).toBe('nmap_tool');
|
||||
expect(tool.category).toBe('network'); // passthrough of the catalog category
|
||||
expect(typeof tool.handler).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adapterToCustomTool — degrade / scope / execution', () => {
|
||||
it('a missing binary DEGRADES (does not throw) and surfaces the installHint', async () => {
|
||||
const deps = makeDeps({ isToolAvailable: async () => false });
|
||||
const tool = mint('nmap', deps);
|
||||
const res = await tool.handler(ctx({ target: '127.0.0.1' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toContain(adapter('nmap').installHint);
|
||||
expect(deps.spawns.length).toBe(0); // nothing was spawned
|
||||
});
|
||||
|
||||
it('scopeOk=false → SCOPE DENIED before the subprocess runs', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => false });
|
||||
const tool = mint('nuclei', deps);
|
||||
const res = await tool.handler(ctx({ url: 'https://evil.example.com' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/SCOPE DENIED/);
|
||||
expect(res.error).toContain('evil.example.com');
|
||||
expect(deps.spawns.length).toBe(0); // refused before spawning
|
||||
});
|
||||
|
||||
it('scopeOk=true → runs the subprocess and returns stdout as output', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('nuclei', deps);
|
||||
const res = await tool.handler(ctx({ url: 'https://target.example.com' }));
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.output).toBe('FAKE_OUTPUT');
|
||||
expect(deps.spawns.length).toBe(1);
|
||||
// nuclei template threads the target through -target
|
||||
expect(deps.spawns[0]).toContain('https://target.example.com');
|
||||
});
|
||||
|
||||
it('a networked adapter with no target degrades gracefully (no spawn)', async () => {
|
||||
const deps = makeDeps();
|
||||
const tool = mint('nuclei', deps);
|
||||
const res = await tool.handler(ctx({}));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/requires a target/);
|
||||
expect(deps.spawns.length).toBe(0);
|
||||
});
|
||||
|
||||
it('a non-zero exit code is reported as failure, not a throw', async () => {
|
||||
const deps = makeDeps({
|
||||
scopeOk: () => true,
|
||||
runSubprocess: async () => ({ stdout: '', stderr: 'boom', exitCode: 2 }),
|
||||
});
|
||||
const tool = mint('nikto', deps);
|
||||
const res = await tool.handler(ctx({ url: 'https://target.example.com' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toContain('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adapterToCustomTool — argument-injection hardening', () => {
|
||||
it('nmap IGNORES an LLM-supplied free-form flags param (no -oN/--script reaches argv)', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('nmap', deps);
|
||||
const res = await tool.handler(ctx({
|
||||
target: 'target.example.com',
|
||||
flags: '-oN /tmp/pwned --script vuln,exploit', // injection attempt
|
||||
}));
|
||||
expect(res.success).toBe(true);
|
||||
const argv = deps.spawns[0];
|
||||
expect(argv).toEqual(['-sV', '-T4', 'target.example.com']); // flags hardcoded; injection dropped
|
||||
expect(argv).not.toContain('-oN');
|
||||
expect(argv.join(' ')).not.toContain('--script');
|
||||
});
|
||||
|
||||
it('nmap accepts a clean port spec but drops one carrying an injected flag', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('nmap', deps);
|
||||
await tool.handler(ctx({ target: 'target.example.com', ports: '22,80,443' }));
|
||||
expect(deps.spawns[0]).toEqual(['-sV', '-T4', '-p', '22,80,443', 'target.example.com']);
|
||||
await tool.handler(ctx({ target: 'target.example.com', ports: '80 -oN /tmp/x' }));
|
||||
expect(deps.spawns[1]).toEqual(['-sV', '-T4', 'target.example.com']); // malicious ports dropped
|
||||
});
|
||||
|
||||
it('refuses an option-looking target (leading dash) before spawning', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('nmap', deps);
|
||||
const res = await tool.handler(ctx({ target: '-oN/tmp/pwned' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/option-looking target/);
|
||||
expect(deps.spawns.length).toBe(0); // never spawned
|
||||
});
|
||||
|
||||
it('curl sends a literal body via --data-raw and REFUSES a local-file-read data value (-d @file)', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = mint('curl', deps);
|
||||
// a clean body is sent verbatim with --data-raw (never plain -d, which would file-interpret @/<)
|
||||
const ok = await tool.handler(ctx({ url: 'https://target.example.com/', data: 'a=1&b=2' }));
|
||||
expect(ok.success).toBe(true);
|
||||
expect(deps.spawns[0]).toContain('--data-raw');
|
||||
expect(deps.spawns[0]).not.toContain('-d');
|
||||
// `@/etc/passwd` (and `<file`) make curl read a local file into the body — must be refused, no spawn
|
||||
for (const evil of ['@/etc/passwd', '@-', '<secret.txt']) {
|
||||
const res = await tool.handler(ctx({ url: 'https://target.example.com/', data: evil }));
|
||||
expect(res.success, `data '${evil}' must be refused`).toBe(false);
|
||||
expect(res.error).toMatch(/read a local file/);
|
||||
}
|
||||
expect(deps.spawns.length).toBe(1); // only the clean call ever spawned
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAdapterTools', () => {
|
||||
it('drops non-mintable adapters (metasploit/hydra/bloodhound never appear)', () => {
|
||||
const tools = buildAdapterTools(TOOL_ADAPTERS, makeDeps());
|
||||
const names = tools.map(t => t.name);
|
||||
expect(names).toContain(toolNameFor(adapter('nmap')));
|
||||
expect(names).not.toContain(toolNameFor(adapter('metasploit')));
|
||||
expect(names).not.toContain(toolNameFor(adapter('hydra')));
|
||||
expect(names).not.toContain(toolNameFor(adapter('bloodhound')));
|
||||
// count matches the catalog's mintable population
|
||||
const mintable = TOOL_ADAPTERS.filter(
|
||||
a => a.execution === 'safe_command' || a.execution === 'receipt_required'
|
||||
).length;
|
||||
expect(tools.length).toBe(mintable);
|
||||
});
|
||||
|
||||
it('skips adapters whose minted name is already registered', () => {
|
||||
const already = new Set([toolNameFor(adapter('nmap'))]);
|
||||
const tools = buildAdapterTools(TOOL_ADAPTERS, makeDeps(), already);
|
||||
expect(tools.map(t => t.name)).not.toContain(toolNameFor(adapter('nmap')));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Shared adjudicate + cite-check (Phase-0 #5) — the two safety primitives lifted from
|
||||
* scripts/refute-finding.mjs into a pure src/mission/adjudicate.ts so the disclosure CLI
|
||||
* and the live pack-audit path can't drift (the pack-hunt design, HT-2).
|
||||
*
|
||||
* Pins: strict-majority thresholds; a MINORITY refute does NOT flip; ties don't refute;
|
||||
* a REFUTED whose killing_guard fails guardExistsInSource is downgraded to SURVIVED;
|
||||
* null/abstain handling never counts as a refute.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
adjudicate,
|
||||
guardExistsInSource,
|
||||
downgradeUnverifiedRefutes,
|
||||
type RefuterVote,
|
||||
} from '../mission/adjudicate.js';
|
||||
|
||||
const refuted = (guard?: { file: string; line: number; quote: string }): RefuterVote => ({
|
||||
verdict: 'REFUTED',
|
||||
killing_guard: guard ?? null,
|
||||
});
|
||||
const survived = (): RefuterVote => ({ verdict: 'SURVIVED', killing_guard: null });
|
||||
|
||||
describe('adjudicate — strict-majority tally', () => {
|
||||
it('2 of 3 REFUTED → REFUTED (strict majority)', () => {
|
||||
const a = adjudicate([refuted(), refuted(), survived()]);
|
||||
expect(a.verdict).toBe('REFUTED');
|
||||
expect(a.refutedCount).toBe(2);
|
||||
expect(a.total).toBe(3);
|
||||
});
|
||||
|
||||
it('1 of 3 REFUTED → SURVIVED (a MINORITY refute does NOT flip)', () => {
|
||||
const a = adjudicate([refuted(), survived(), survived()]);
|
||||
expect(a.verdict).toBe('SURVIVED');
|
||||
expect(a.refutedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('a TIE does not refute — 1 of 2 REFUTED → SURVIVED (need STRICT majority)', () => {
|
||||
expect(adjudicate([refuted(), survived()]).verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('a TIE does not refute — 2 of 4 REFUTED → SURVIVED', () => {
|
||||
expect(adjudicate([refuted(), refuted(), survived(), survived()]).verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('3 of 4 REFUTED → REFUTED', () => {
|
||||
expect(adjudicate([refuted(), refuted(), refuted(), survived()]).verdict).toBe('REFUTED');
|
||||
});
|
||||
|
||||
it('unanimous single REFUTED → REFUTED; single SURVIVED → SURVIVED', () => {
|
||||
expect(adjudicate([refuted()]).verdict).toBe('REFUTED');
|
||||
expect(adjudicate([survived()]).verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('collects killing guards from the counted REFUTED votes (nulls dropped)', () => {
|
||||
const g = { file: 'x.c', line: 9, quote: 'if (n > cap) return' };
|
||||
const a = adjudicate([refuted(g), refuted(g), survived()]);
|
||||
expect(a.killing_guards).toHaveLength(2);
|
||||
// a REFUTED with a null guard contributes no entry
|
||||
const b = adjudicate([refuted(g), refuted(), refuted()]);
|
||||
expect(b.killing_guards).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjudicate — null / abstain handling', () => {
|
||||
it('null and junk verdicts are dropped: neither refute count nor panel size', () => {
|
||||
// 1 real REFUTED, plus a null and a junk vote → panel size 1, not a majority-of-3
|
||||
const a = adjudicate([refuted(), null, { verdict: 'MAYBE' } as RefuterVote]);
|
||||
expect(a.total).toBe(1);
|
||||
expect(a.refutedCount).toBe(1);
|
||||
expect(a.verdict).toBe('REFUTED'); // 1 of 1 valid
|
||||
});
|
||||
|
||||
it('abstentions cannot manufacture a majority — 1 REFUTED + 2 abstain among 2 survived stays SURVIVED', () => {
|
||||
const a = adjudicate([refuted(), survived(), survived(), null, { verdict: 'junk' } as RefuterVote]);
|
||||
expect(a.total).toBe(3);
|
||||
expect(a.verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('empty panel → INCONCLUSIVE', () => {
|
||||
expect(adjudicate([]).verdict).toBe('INCONCLUSIVE');
|
||||
expect(adjudicate([null, undefined, { verdict: 'junk' } as RefuterVote]).verdict).toBe('INCONCLUSIVE');
|
||||
expect(adjudicate([null]).total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardExistsInSource — deterministic cite-check', () => {
|
||||
const source = [
|
||||
'void copy(size_t n) {',
|
||||
' if (n > cap) return; // the real killing guard',
|
||||
' memcpy(dst, src, n);',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
it('verifies an exact quote (normalized substring, whitespace-insensitive)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, source)).toBe(true);
|
||||
// reformatted (extra spaces / different indent) still verifies
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if(n>cap) return' }, source)).toBe(true);
|
||||
});
|
||||
|
||||
it('verifies a reversed-order comparison via the structural fallback (operator mirrored)', () => {
|
||||
// not a substring (operands swapped), but source `n > cap` ≡ cited `cap < n` — same guard, mirrored.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap < n' }, source)).toBe(true);
|
||||
// same operands + same operator, just reformatted, also verifies structurally.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n>cap' }, source)).toBe(true);
|
||||
});
|
||||
|
||||
it('REJECTS a same-operand comparison with the WRONG operator (a different guard, not a paraphrase)', () => {
|
||||
// source guard is `n > cap`; a cited `n >= cap` is a DIFFERENT bound (it also blocks n==cap) and
|
||||
// must NOT verify just because it shares the operands — the operator carries the guard's meaning.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n >= cap' }, source)).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'n == cap' }, source)).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap <= n' }, source)).toBe(false); // wrong mirror
|
||||
});
|
||||
|
||||
// --- regex-literal tokenizing (adversarial review sixth pass): a quote INSIDE a regex must not open
|
||||
// a phantom string that swallows a real guard on a later line. ---
|
||||
it('a quote inside a regex literal does NOT blank a real guard on a later line', () => {
|
||||
const src = [
|
||||
`function handle(idx, buf) {`,
|
||||
` const cleaned = name.replace(/['"]/g, '');`, // ubiquitous sanitizer — quotes live in the regex
|
||||
` if (idx >= buf.length) return ERR;`, // the real killing guard, on the NEXT line
|
||||
` return buf[idx];`,
|
||||
`}`,
|
||||
].join('\n');
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 3, quote: 'idx >= buf.length' }, src)).toBe(true);
|
||||
});
|
||||
|
||||
it('an apostrophe-bearing regex does NOT blank a downstream comparison guard', () => {
|
||||
const src = [
|
||||
`s = s.replace(/'/g, "x");`, // a stray apostrophe inside the regex
|
||||
`if (userId >= tenant.limit) throw new Error('scope');`, // the real guard must still verify
|
||||
].join('\n');
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 2, quote: 'userId >= tenant.limit' }, src)).toBe(true);
|
||||
});
|
||||
|
||||
it('still REJECTS a comparison that lives ONLY inside a regex literal (no false-verify)', () => {
|
||||
// the regex body is blanked like a string, so a hallucinated guard cannot hide there.
|
||||
const src = `const re = /idx >= len/; return arr[idx];`;
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, src)).toBe(false);
|
||||
});
|
||||
|
||||
// --- seventh-pass BLOCKER: a regex in VALUE position (return/typeof/close-paren) must NOT leak its
|
||||
// body — else a comparison that only lives inside the regex false-verifies a hallucinated guard. ---
|
||||
it('a value-position regex body cannot false-verify a hallucinated guard (return / if(x) /re/)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.ts', line: 3, quote: 'm_id >= ls.w' },
|
||||
'const cell = ls.cells[m_id];\nreturn /m_id >= ls.w/.test(String(m_id));')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a >= b' }, 'return /a >= b/.test(x);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a >= b' }, 'if (x) /a >= b/.test(y);')).toBe(false);
|
||||
});
|
||||
|
||||
it('over-blanks a JS guard sharing a line AFTER a regex (safe: SURVIVED), keeps it in a no-regex language', () => {
|
||||
// A regex-bearing language (JS): everything after a regex `/` on the line is conservatively blanked
|
||||
// to end-of-line, so this same-line-after-regex guard does NOT verify — a false-REJECT (→ the
|
||||
// finding SURVIVES), the module's safe direction. The realistic case (guard on its OWN line) still
|
||||
// verifies (covered by the tests above).
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx < len' },
|
||||
`function f(s, idx, len) { return /['"]/.test(s) && idx < len; }`)).toBe(false);
|
||||
// A no-regex language (C/C++/…) keeps every `/` as division, so a real comparison sharing a line
|
||||
// with a division verifies at full fidelity.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < len' },
|
||||
'int g(int idx, int len) { int r = a / b; if (idx < len) return r; }')).toBe(true);
|
||||
});
|
||||
|
||||
it('a single division on the guard line is preserved (idx >= len / 2)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len / 2' }, 'if (idx >= len / 2) return;')).toBe(true);
|
||||
});
|
||||
|
||||
// --- 8th-pass BLOCKER: a DIVISION `/` earlier on a line must not mis-pair with a later regex's
|
||||
// opening `/` (which would leak the regex body) or with a comment's `/` (leaking the comment). ---
|
||||
it('a division before a regex does not leak the regex body (no mis-pairing false-verify)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'y = a / b; const re = /idx >= len/.test(z);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'y = foo() / b; const re = /idx >= len/.test(z);')).toBe(false);
|
||||
});
|
||||
|
||||
it('a division before a comment does not leak the comment body', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'lo <= hi' }, 'y = w / 2; // lo <= hi')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'lo <= hi' }, 'y = w / 2; /* lo <= hi */ z = 0;')).toBe(false);
|
||||
});
|
||||
|
||||
it('a division after a CALL is preserved, and a regex after a CONTROL-flow ) is blanked', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx < getLen() / 2' }, 'if (idx < getLen() / 2) return;')).toBe(true); // call-div kept
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'if (x) /idx >= len/.test(y);')).toBe(false); // control-) regex blanked
|
||||
});
|
||||
|
||||
// --- 9th-pass BLOCKERS: a regex after a block-closing `}`, after a keyword-as-PROPERTY, or after a
|
||||
// block comment must not leak its body (the position-heuristic lexers all missed these). ---
|
||||
it('a regex after a block-closing } does not leak its body', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, '{ let a = 1; } /idx > len/.test(input);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'if (c) { work(); } /idx > len/.exec(s);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'function h(){ return 0; } /idx > len/.test(q);')).toBe(false);
|
||||
});
|
||||
|
||||
it('a regex after a keyword used as a PROPERTY (o.in / c) does not leak (no division mis-pair)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'const n = o.in / c; return raw.replace(/idx > len/g,"");')).toBe(false);
|
||||
});
|
||||
|
||||
it('a regex after a block comment does not leak its body', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, '/* note */ const re = /idx > len/.test(s);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx > len' }, 'if /*c*/ (x) /idx > len/.test(y);')).toBe(false);
|
||||
});
|
||||
|
||||
it('a regex containing BOTH a comparison and a quote does not leak (/a > b\'/)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, "z = /a > b'/.test(s);")).toBe(false);
|
||||
});
|
||||
|
||||
// --- 10th-pass BLOCKERS (fixed by deciding regex-vs-division by LANGUAGE, not per-`/`): a regex-
|
||||
// internal quote must not consume a later real string's quote, and a `\`-continued string must
|
||||
// not leak its comparison. ---
|
||||
it('a regex-internal quote does not consume a later string, leaking its comparison', () => {
|
||||
expect(guardExistsInSource({ file: 'x.ts', line: 1, quote: 'b > c' }, "const r = /a'/.test(x) ? y : 'b > c';")).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'm_id >= ls.w' }, "if (/x'/.test(name)) log('m_id >= ls.w');")).toBe(false);
|
||||
});
|
||||
|
||||
it('a backslash-continued string does not leak its comparison (JS and C)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'var s = "boundary idx >= len \\\nmore"; buf[idx]=v;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= len' }, 'char* s = "boundary idx >= len \\\nmore"; buf[idx]=v;')).toBe(false);
|
||||
});
|
||||
|
||||
it('a no-regex language (.c) keeps divisions at full fidelity (a division never blanks a real guard)', () => {
|
||||
// In C there are no `/…/` regex literals, so `/` is always division; a real bounds guard sharing a
|
||||
// line with a division still verifies — no over-blank, unlike the regex-bearing (JS) path.
|
||||
expect(guardExistsInSource({ file: 'los.c', line: 2, quote: 'm_id >= ls_w' }, 'int cell(int m_id, int ls_w) {\n if (m_id >= ls_w) return -1; return buf[m_id] / scale;\n}')).toBe(true);
|
||||
});
|
||||
|
||||
// --- 11th-pass BLOCKERS: multi-language comment / string handling (the tokenizer is per-language). ---
|
||||
it('a JS nested template literal does not leak its inner string as code', () => {
|
||||
expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'a > b' }, 't = `${`a > b`}`;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.js', line: 1, quote: 'idx >= len' }, 'h = `${`if (idx >= len) continue`}`;')).toBe(false);
|
||||
});
|
||||
|
||||
it('a \\-continued string does not leak across the newline (LF and CRLF)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= len' }, 'char* s = "x idx >= len \\\nmore"; return buf[idx];')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'var s = "x idx >= len \\\r\nmore"; buf[idx]=v;')).toBe(false);
|
||||
});
|
||||
|
||||
it('Python: # comments and triple-quoted strings are stripped, but // is floor-division (kept)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.py', line: 1, quote: 'idx >= len' }, 'x = 1 # idx >= len is the intended check\nreturn buf[idx]')).toBe(false); // # comment
|
||||
expect(guardExistsInSource({ file: 'a.py', line: 2, quote: 'idx >= len' }, 'def f():\n """note: idx >= len denotes overflow"""\n return buf[idx]')).toBe(false); // triple-quote
|
||||
expect(guardExistsInSource({ file: 'a.py', line: 2, quote: 'idx >= n' }, 'h = a // b\nif idx >= n: return -1')).toBe(true); // // is floor-div, guard verifies
|
||||
});
|
||||
|
||||
it('a C backslash-continued // comment does not leak onto the next line', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'idx >= len' }, 'int f() { // cont \\\nidx >= len leaks?\n return 0; }')).toBe(false);
|
||||
});
|
||||
|
||||
it('# (shell) and -- (SQL/Lua) line comments are stripped for those languages', () => {
|
||||
expect(guardExistsInSource({ file: 'run.sh', line: 1, quote: 'idx >= len' }, 'echo hi # idx >= len check needed')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'q.sql', line: 1, quote: 'a >= b' }, 'select 1; -- a >= b intended')).toBe(false);
|
||||
});
|
||||
|
||||
// --- 12th-pass: raw-string forms (Go / C++ / Rust) must not leak their body ---
|
||||
it('raw-string literals (Go backtick, C++ R"(...)", Rust r#"..."#) do not leak a comparison', () => {
|
||||
expect(guardExistsInSource({ file: 'x.go', line: 1, quote: 'idx >= len' }, 'q := `SELECT * WHERE idx >= len`\nreturn buf[idx]')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.cpp', line: 1, quote: 'n > cap' }, 'auto s = R"(payload n > cap here)";\nint real = 0;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.cpp', line: 1, quote: 'n > cap' }, 'auto s = R"xy(has a \\" then n > cap here)xy"; int r = 0;')).toBe(false); // inner-quote parity
|
||||
expect(guardExistsInSource({ file: 'x.rs', line: 1, quote: 'n > cap' }, 'let s = r#"note n > cap and a " quote"#; let r = 0;')).toBe(false);
|
||||
// and real guards in those languages still verify
|
||||
expect(guardExistsInSource({ file: 'x.go', line: 1, quote: 'idx >= len' }, 'if idx >= len { return }')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.rs', line: 1, quote: 'n > cap' }, 'if n > cap { return; }')).toBe(true);
|
||||
});
|
||||
|
||||
it('heredoc bodies (shell <<EOF / <<- / <<~ / quoted, ruby <<~) do not leak; code after them survives', () => {
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'x > y' }, 'cat <<EOF\nif (( x > y )); then :; fi\nEOF\necho done')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'a >= b' }, 'cat <<-EOF\n\ta >= b here\n\tEOF\n:')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'i >= len' }, 't = <<~SQL\n if i >= len then end\nSQL\nputs t')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 4, quote: 'n > cap' }, 'cat <<EOF\nbody\nEOF\nif (( n > cap )); then :; fi')).toBe(true); // code after heredoc
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'n > cap' }, 'arr << item\nif n > cap then end')).toBe(true); // `<<` left-shift, not a heredoc
|
||||
});
|
||||
|
||||
it('ambiguous extensions (.m, .pl) use the safe superset — a % comment cannot leak', () => {
|
||||
expect(guardExistsInSource({ file: 'x.m', line: 1, quote: 'a >= b' }, 'y = 1 % a >= b intended check\nz = 2')).toBe(false); // MATLAB %
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a >= b' }, 'foo :- bar. % a >= b\nbaz.')).toBe(false); // Prolog %
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 1, quote: 'idx >= len' }, 'x = %q(note idx >= len here)\ny = 1')).toBe(false); // Ruby %q over-blanked
|
||||
});
|
||||
|
||||
it('the `\\`-line-comment splice fires ONLY for C/C++ (JS/Python `//`/`#` do not splice)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.c', line: 2, quote: 'idx >= len' }, 'int f() { // cont \\\nidx >= len leaks?\n return 0; }')).toBe(false); // C splices
|
||||
expect(guardExistsInSource({ file: 'x.js', line: 2, quote: 'idx >= len' }, 'foo(); // note ends with \\\nif (idx >= len) return;')).toBe(true); // JS does NOT splice
|
||||
expect(guardExistsInSource({ file: 'x.py', line: 2, quote: 'idx >= len' }, 'x = 1 # note ends with \\\nif idx >= len: return')).toBe(true); // Python does NOT splice
|
||||
});
|
||||
|
||||
it('shell `<<<` here-string is NOT a heredoc (does not swallow the following code)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'n > cap' }, 'grep foo <<<word\nif (( n > cap )); then :; fi')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 2, quote: 'x > y' }, 'cat <<EOF\nx > y\nEOF\n:')).toBe(false); // a real heredoc still blanks
|
||||
});
|
||||
|
||||
// --- 13th-pass: Perl POD / __END__ / quote-like operators (q// qq{} s/// tr/// m// qr// qw[]) ---
|
||||
it('Perl quote-like operators do not leak their body', () => {
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'i >= size' }, 'my $s = q(check i >= size before use);')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a > b' }, '$x =~ s/note a > b here/X/g;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a >= b' }, 'if ($x =~ m{a >= b}) { }')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'a > b' }, 'my @w = qw[a > b c];')).toBe(false);
|
||||
});
|
||||
|
||||
it('Perl POD (=word..=cut) and __END__/__DATA__ blocks do not leak', () => {
|
||||
expect(guardExistsInSource({ file: 'Auth.pm', line: 3, quote: 'idx < len' }, 'package Auth;\n=head1 SAFETY\nrefused unless idx < len upstream.\n=cut\nsub access { return $arr[0]; }')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'Q.pm', line: 3, quote: 'count > limit' }, 'sub f { 1 }\n__END__\ncount > limit is enforced\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('Perl quote-op lexing does not misfire on ordinary Perl (my/sub/hash), and a real guard verifies', () => {
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: '$i >= $size' }, 'sub f {\n my $x = 1;\n if ($i >= $size) { return; }\n}')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 2, quote: '$n > $cap' }, 'my $v = $h->{q};\nif ($n > $cap) { die }')).toBe(true);
|
||||
});
|
||||
|
||||
// --- 14th-pass fixes ---
|
||||
it('a single-parameter generic Type<Param> is NOT minted as a comparison guard (C++/Java/Rust/TS)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.cpp', line: 1, quote: 'vector < int' }, 'std::vector<int> cache;\nreturn cache[i];')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.java', line: 1, quote: 'List < String' }, 'List<String> names = new ArrayList<>();')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.rs', line: 1, quote: 'Vec < u8' }, 'let mut buf: Vec<u8> = vec![];')).toBe(false);
|
||||
// a genuine single-`<`/`>` guard still verifies (via the operand-checked structural path)
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < len' }, 'if (idx < len) ok();')).toBe(true);
|
||||
});
|
||||
|
||||
it('a sigil-dropped citation verifies against $-prefixed operands (shell/Perl/PHP)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.sh', line: 1, quote: 'n > cap' }, 'if (( $n > $cap )); then :; fi')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 1, quote: 'i >= size' }, 'if ($i >= $size) { return; }')).toBe(true);
|
||||
});
|
||||
|
||||
it('PostgreSQL dollar-quoted strings ($$…$$ / $tag$…$tag$) do not leak; $N params are not confused', () => {
|
||||
expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'n > cap' }, 'SELECT $$ if n > cap then $$;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'idx >= len' }, 'SELECT $body$ where idx >= len $body$ AS note;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.sql', line: 1, quote: 'a > b' }, 'SELECT $1 WHERE a > b;')).toBe(true); // $1 is a param, not a dollar-quote
|
||||
});
|
||||
|
||||
it('a comparison inside a JS `${…}` template interpolation does NOT verify (display text, not a bounds guard) and nested templates never leak', () => {
|
||||
// A comparison in a `${…}` interpolation produces template display text, not a killing guard — treat
|
||||
// it conservatively (blank), so a hallucinated guard citing it cannot false-VERIFY. Over-rejecting a
|
||||
// (contrived) real guard hidden inside an interpolation is the SAFE direction.
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'const s = `v ${idx >= len ? 1 : 0} w`; return;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, 't = `${`a > b`}`;')).toBe(false);
|
||||
// interpolation reached THROUGH a nested template (a string) must not leak either
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'a > b' }, 'x = `${ `${ a > b }` }`;')).toBe(false);
|
||||
// a real guard OUTSIDE any template still verifies
|
||||
expect(guardExistsInSource({ file: 'a.js', line: 1, quote: 'idx >= len' }, 'const s = `hi ${name}`; if (idx >= len) return;')).toBe(true);
|
||||
});
|
||||
|
||||
it('multi-parameter generics never false-VERIFY a `<` guard, and real `<` guards still verify', () => {
|
||||
expect(guardExistsInSource({ file: 'm.ts', line: 1, quote: 'Map < K' }, 'const m: Map<K, V> = new Map();')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'HashMap < K' }, 'let m: HashMap<K, V> = h();')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'Nested < Vec' }, 'let x: Nested<Vec<int>> = y;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'm.rs', line: 1, quote: 'Box < T' }, 'let b: Box<T> = z;')).toBe(false); // single-arg still rejected
|
||||
expect(guardExistsInSource({ file: 'm.ts', line: 1, quote: 'idx < len' }, 'if (idx < len) return;')).toBe(true);
|
||||
});
|
||||
|
||||
it('PHP heredoc/nowdoc bodies are blanked; `<<` left-shift is not a heredoc', () => {
|
||||
expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'idx >= len' }, '$s = <<<EOT\nif (idx >= len) leak\nEOT;\n$r = 0;')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'n > cap' }, "$s = <<<'EOT'\nnote n > cap here\nEOT;\n$r = 0;")).toBe(false); // nowdoc
|
||||
expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'x > y' }, '$s = <<<EOT\n x > y here\n EOT;\n$r = 0;')).toBe(false); // 7.3+ indented closer
|
||||
expect(guardExistsInSource({ file: 'x.php', line: 2, quote: 'n > cap' }, '$y = $x << 2;\nif (n > cap) return;')).toBe(true); // `<<` = left-shift, guard survives
|
||||
});
|
||||
|
||||
it('an INDENTED __END__/__DATA__ is ordinary code (only column-0 is a Perl trailer)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: 'n > cap' }, 'sub f {\n __END__\n if (n > cap) { return }\n}')).toBe(true);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: 'a > b' }, 'code();\n__END__\na > b in pod trailer')).toBe(false);
|
||||
});
|
||||
|
||||
it('Perl __END__ with trailing text is blanked, and a stray leading `=` without `=cut` stays code', () => {
|
||||
expect(guardExistsInSource({ file: 'Q.pm', line: 3, quote: 'count > limit' }, 'sub f { 1 }\n__END__ data\ncount > limit\n')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.pl', line: 3, quote: '$n > $cap' }, 'my $r = 1\n=x\nif ($n > $cap) { die }')).toBe(true); // no =cut → not POD
|
||||
});
|
||||
|
||||
it('a bare Ruby `<<WORD` heredoc blanks its body only when a terminator exists (not `arr << CONST`)', () => {
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'i > n' }, 'q = <<SQL\n where i > n\nSQL\nputs q')).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'x.rb', line: 2, quote: 'n > cap' }, 'arr << CONST\nif n > cap then end')).toBe(true);
|
||||
});
|
||||
|
||||
it('REJECTS a hallucinated guard not present in source', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (m_id >= ls.w) continue;' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a paraphrased comparison whose operands never co-occur on one line', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'foo >= bar' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS on empty/short quote, empty source, or null guard', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: '' }, source)).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, '')).toBe(false);
|
||||
expect(guardExistsInSource(null, source)).toBe(false);
|
||||
// sub-6-char quote falls through the substring path; no comparison → false
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'x;' }, source)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardExistsInSource — hardened against hallucinated cites (HT-2 regression)', () => {
|
||||
const source = [
|
||||
'void copy(size_t n) {',
|
||||
' if (n > cap) return; // the real killing guard',
|
||||
' memcpy(dst, src, n);',
|
||||
'}',
|
||||
].join('\n');
|
||||
// an OOB sink with NO bounds check at all — the exact case a hallucinating refuter targets
|
||||
const unbounded = 'void f(int idx){ memcpy(dst, table[idx], width); }';
|
||||
|
||||
it('REJECTS a structural cite whose operands co-occur on a line but are never actually compared', () => {
|
||||
// `n` and `src` both appear on the memcpy line, but `n > src` is not a comparison anywhere.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 3, quote: 'n > src' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a fabricated bounds check on an unbounded sink (operands present, comparison absent)', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx >= width' }, unbounded)).toBe(false);
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'idx < width' }, unbounded)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a comparison of two tokens that are never compared together in source', () => {
|
||||
// source only ever compares n vs cap; {cap, dst} is not a real comparison.
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'cap > dst' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a bare common token that IS a substring but has no guard shape', () => {
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'size_t' }, source)).toBe(false);
|
||||
// the sink line itself is not a guard, even though it is a literal substring
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 3, quote: 'memcpy(dst, src, n)' }, source)).toBe(false);
|
||||
});
|
||||
|
||||
it('still verifies a genuine clamp/min guard cited exactly (no false-negative regression)', () => {
|
||||
const clamp = 'n = std::min(n, cap);';
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'std::min(n, cap)' }, clamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('REJECTS a guard that exists only in a COMMENT, not real code (comment stripping)', () => {
|
||||
const withComment = 'x = alloc();\n// if (n > cap) return; (just a note, not real code)\ny = use(x);';
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 2, quote: 'if (n > cap) return' }, withComment)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a guard that only matches by STITCHING across two unrelated lines', () => {
|
||||
const twoLines = 'if (done) break;\nprocess(data);';
|
||||
// "break;process" is a substring of the whitespace-flattened file, but never appears on ONE line
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'break; process' }, twoLines)).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a guard that exists only inside a STRING LITERAL, not real code', () => {
|
||||
const inString = 'const msg = "if (n > cap) return";\nprocess(msg);';
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'if (n > cap) return' }, inString)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT false-reject a real guard sharing a line with a // inside a STRING literal', () => {
|
||||
const withUrl = 'const u = "http://x/y"; if (n > cap) return;'; // the // is inside the string, not a comment
|
||||
expect(guardExistsInSource({ file: 'a.c', line: 1, quote: 'n > cap' }, withUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it('END-TO-END: two hallucinated structural refutes no longer kill a real finding', () => {
|
||||
const resolve = (f: string): string => (f === 'sink.c' ? unbounded : '');
|
||||
const raw = [
|
||||
refuted({ file: 'sink.c', line: 1, quote: 'idx >= width' }), // fabricated bound
|
||||
refuted({ file: 'sink.c', line: 1, quote: 'idx < width' }), // fabricated bound
|
||||
survived(),
|
||||
];
|
||||
const checked = downgradeUnverifiedRefutes(raw, resolve);
|
||||
expect(adjudicate(checked).verdict).toBe('SURVIVED');
|
||||
expect(adjudicate(checked).refutedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downgradeUnverifiedRefutes — mandatory cite-check flips hallucinated refutes', () => {
|
||||
const realSource = 'if (n > cap) return;\nmemcpy(dst, src, n);';
|
||||
const resolve = (file: string): string => (file === 'real.c' ? realSource : '');
|
||||
|
||||
it('a REFUTED with a VERIFIED guard survives the check (stays REFUTED)', () => {
|
||||
const votes = [refuted({ file: 'real.c', line: 1, quote: 'if (n > cap) return' })];
|
||||
const out = downgradeUnverifiedRefutes(votes, resolve);
|
||||
expect(out[0]?.verdict).toBe('REFUTED');
|
||||
expect(out[0]?.guard_check).toBe('verified');
|
||||
});
|
||||
|
||||
it('a REFUTED whose killing_guard FAILS guardExistsInSource is downgraded to SURVIVED', () => {
|
||||
const votes = [refuted({ file: 'real.c', line: 1, quote: 'if (bogus >= nope) skip' })];
|
||||
const out = downgradeUnverifiedRefutes(votes, resolve);
|
||||
expect(out[0]?.verdict).toBe('SURVIVED');
|
||||
expect(out[0]?.original_verdict).toBe('REFUTED');
|
||||
expect(out[0]?.guard_check).toBe('unverified');
|
||||
});
|
||||
|
||||
it('a REFUTED citing a MISSING file is downgraded (resolve returns empty)', () => {
|
||||
const votes = [refuted({ file: 'ghost.c', line: 1, quote: 'if (n > cap) return' })];
|
||||
expect(downgradeUnverifiedRefutes(votes, resolve)[0]?.verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('a REFUTED with NO killing_guard cannot stand on a cite → downgraded', () => {
|
||||
expect(downgradeUnverifiedRefutes([refuted()], resolve)[0]?.verdict).toBe('SURVIVED');
|
||||
});
|
||||
|
||||
it('SURVIVED votes and nulls pass through unchanged; the check is pure (input not mutated)', () => {
|
||||
const votes: Array<RefuterVote | null> = [survived(), null];
|
||||
const out = downgradeUnverifiedRefutes(votes, resolve);
|
||||
expect(out[0]?.verdict).toBe('SURVIVED');
|
||||
expect(out[1]).toBeNull();
|
||||
// purity: the original REFUTED vote object is not mutated by the downgrade
|
||||
const original = refuted({ file: 'ghost.c', line: 1, quote: 'nope nope' });
|
||||
downgradeUnverifiedRefutes([original], resolve);
|
||||
expect(original.verdict).toBe('REFUTED');
|
||||
expect(original.original_verdict).toBeUndefined();
|
||||
});
|
||||
|
||||
it('END-TO-END: a minority-verified + majority-hallucinated panel does NOT refute after the cite-check', () => {
|
||||
// 2 hallucinated REFUTED + 1 SURVIVED: raw tally would REFUTE (2 of 3).
|
||||
const raw = [
|
||||
refuted({ file: 'real.c', line: 1, quote: 'if (madeup > 999) die' }),
|
||||
refuted({ file: 'real.c', line: 1, quote: 'if (alsofake < 0) bail' }),
|
||||
survived(),
|
||||
];
|
||||
expect(adjudicate(raw).verdict).toBe('REFUTED'); // before cite-check: wrongly refuted
|
||||
const checked = downgradeUnverifiedRefutes(raw, resolve);
|
||||
expect(adjudicate(checked).verdict).toBe('SURVIVED'); // after cite-check: real finding preserved
|
||||
expect(adjudicate(checked).refutedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Capability approval + spicy-action warning gate. Pins the safety contract:
|
||||
* - ungated tools pass straight through; gated (intrusive/credential/dangerous) do not.
|
||||
* - FAIL-SAFE: gated + no approver + not pre-approved → DENIED (unattended runs don't self-fire).
|
||||
* - "approve once, then free": the interactive approver is asked ONCE per tool, then it runs free.
|
||||
* - pre-authorization allowlist runs free (headless).
|
||||
* - spicy actions fire a NON-BLOCKING warning every time an approved call runs; everything audited.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
ApprovalController,
|
||||
isGatedRisk,
|
||||
isSpicyRisk,
|
||||
type ApprovalRequest,
|
||||
type RiskTier,
|
||||
} from '../arsenal/approval.js';
|
||||
|
||||
const req = (over: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
|
||||
tool: 'metasploit',
|
||||
risk: 'dangerous',
|
||||
operator: 'exploit-dev',
|
||||
target: '10.0.0.5',
|
||||
action: 'metasploit exploit/x → 10.0.0.5',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('risk classification', () => {
|
||||
it('gates only intrusive / credential / dangerous', () => {
|
||||
const gated: RiskTier[] = ['intrusive', 'credential', 'dangerous'];
|
||||
const open: RiskTier[] = ['local_read', 'passive', 'active'];
|
||||
for (const r of gated) expect(isGatedRisk(r)).toBe(true);
|
||||
for (const r of open) expect(isGatedRisk(r)).toBe(false);
|
||||
expect(isGatedRisk(undefined)).toBe(false);
|
||||
});
|
||||
it('marks credential + dangerous as spicy (loud-warn), not intrusive', () => {
|
||||
expect(isSpicyRisk('dangerous')).toBe(true);
|
||||
expect(isSpicyRisk('credential')).toBe(true);
|
||||
expect(isSpicyRisk('intrusive')).toBe(false);
|
||||
expect(isSpicyRisk('active')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApprovalController.gate', () => {
|
||||
it('lets an ungated (active) tool straight through, no record', async () => {
|
||||
const c = new ApprovalController();
|
||||
const d = await c.gate(req({ tool: 'nmap', risk: 'active' }));
|
||||
expect(d.allowed).toBe(true);
|
||||
expect(c.getAudit()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FAIL-SAFE: a gated tool with no approver and no allowlist is DENIED', async () => {
|
||||
const c = new ApprovalController(); // nothing wired
|
||||
const d = await c.gate(req());
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toMatch(/not authorized/);
|
||||
expect(c.getAudit()[0]?.outcome).toBe('denied-no-approver');
|
||||
});
|
||||
|
||||
it('pre-authorization allowlist runs free (headless), and audits', async () => {
|
||||
const c = new ApprovalController({ preApprovedTools: ['metasploit', 'hydra'] });
|
||||
const d = await c.gate(req());
|
||||
expect(d.allowed).toBe(true);
|
||||
expect(c.getAudit()[0]?.outcome).toBe('allowed-preapproved');
|
||||
// a tool NOT on the list is still denied
|
||||
expect((await c.gate(req({ tool: 'sqlmap', risk: 'intrusive' }))).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('interactive: approve ONCE, then free — the approver is asked exactly one time', async () => {
|
||||
const requestApproval = vi.fn(async () => true);
|
||||
const c = new ApprovalController({ requestApproval });
|
||||
expect((await c.gate(req())).allowed).toBe(true); // asks, approves
|
||||
expect((await c.gate(req())).allowed).toBe(true); // free — no second ask
|
||||
expect((await c.gate(req())).allowed).toBe(true);
|
||||
expect(requestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(c.isApproved('metasploit')).toBe(true);
|
||||
expect(c.getAudit().map((r) => r.outcome)).toEqual([
|
||||
'allowed-interactive',
|
||||
'allowed-preapproved',
|
||||
'allowed-preapproved',
|
||||
]);
|
||||
});
|
||||
|
||||
it('interactive decline denies and does NOT approve the tool', async () => {
|
||||
const requestApproval = vi.fn(async () => false);
|
||||
const c = new ApprovalController({ requestApproval });
|
||||
const d = await c.gate(req());
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toMatch(/declined/);
|
||||
expect(c.isApproved('metasploit')).toBe(false);
|
||||
expect(c.getAudit()[0]?.outcome).toBe('denied-declined');
|
||||
});
|
||||
|
||||
it('spicy actions fire a NON-BLOCKING warning every allowed run; ungated-but-approved does not', async () => {
|
||||
const onWarning = vi.fn();
|
||||
const c = new ApprovalController({ preApprovedTools: ['metasploit', 'sqlmap'], onWarning });
|
||||
await c.gate(req()); // dangerous → spicy → warns
|
||||
await c.gate(req()); // still warns on every run
|
||||
await c.gate(req({ tool: 'sqlmap', risk: 'intrusive' })); // gated but NOT spicy → no warn
|
||||
expect(onWarning).toHaveBeenCalledTimes(2);
|
||||
expect(onWarning.mock.calls[0][0].tool).toBe('metasploit');
|
||||
// the warning never blocks — all three calls were allowed
|
||||
expect(c.getAudit().every((r) => r.outcome.startsWith('allowed'))).toBe(true);
|
||||
});
|
||||
|
||||
it('audit trail is a defensive copy carrying who/what/target/outcome/spicy/time', async () => {
|
||||
const c = new ApprovalController({ preApprovedTools: ['hydra'], now: () => 1234 });
|
||||
await c.gate(req({ tool: 'hydra', risk: 'credential', action: 'hydra ssh 10.0.0.5' }));
|
||||
const audit = c.getAudit();
|
||||
expect(audit[0]).toMatchObject({
|
||||
tool: 'hydra',
|
||||
risk: 'credential',
|
||||
operator: 'exploit-dev',
|
||||
spicy: true,
|
||||
outcome: 'allowed-preapproved',
|
||||
at: 1234,
|
||||
});
|
||||
audit[0].tool = 'MUTATED';
|
||||
expect(c.getAudit()[0].tool).toBe('hydra'); // internal trail untouched
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Arsenal.execute() enforces the capability-approval gate for gated tools, alongside the scope gate.
|
||||
* A dangerous/credential/intrusive tool must not run until approved; ungated tools are untouched; the
|
||||
* gate runs BEFORE the handler (a denied call never executes).
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Arsenal, BUILTIN_TOOLS, EXTERNAL_TOOLS, SPICY_BUILTIN_TIERS, stampSpicyBuiltin } from '../arsenal/index.js';
|
||||
import { ApprovalController } from '../arsenal/approval.js';
|
||||
import type { CustomTool, ToolContext } from '../types/index.js';
|
||||
|
||||
const ctx = (parameters: Record<string, unknown> = {}): ToolContext => ({ parameters });
|
||||
|
||||
function probe(name: string, riskTier?: CustomTool['riskTier']): CustomTool & { ran: () => boolean } {
|
||||
let ran = false;
|
||||
const tool: CustomTool = {
|
||||
name, description: 'x', category: 'exploitation', riskTier, parameters: [],
|
||||
handler: async () => { ran = true; return { success: true, output: 'DID_RUN' }; },
|
||||
};
|
||||
return Object.assign(tool, { ran: () => ran });
|
||||
}
|
||||
|
||||
describe('Arsenal.execute() — approval gate', () => {
|
||||
it('no controller wired = gating off (backward-compat): a dangerous tool still runs', async () => {
|
||||
const ars = new Arsenal();
|
||||
const t = probe('metasploit_module', 'dangerous');
|
||||
ars.register(t);
|
||||
expect((await ars.execute('metasploit_module', ctx())).success).toBe(true);
|
||||
expect(t.ran()).toBe(true);
|
||||
});
|
||||
|
||||
it('fail-safe: a gated tool is DENIED (handler never runs) when nothing approves it', async () => {
|
||||
const ars = new Arsenal();
|
||||
const t = probe('metasploit_module', 'dangerous');
|
||||
ars.register(t);
|
||||
ars.setApprovalController(new ApprovalController()); // no approver, no allowlist
|
||||
const res = await ars.execute('metasploit_module', ctx());
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/APPROVAL REQUIRED/);
|
||||
expect(t.ran()).toBe(false); // gate ran before the handler
|
||||
});
|
||||
|
||||
it('a pre-authorized gated tool runs, and a spicy call fires the warning', async () => {
|
||||
const ars = new Arsenal();
|
||||
const onWarning = vi.fn();
|
||||
const t = probe('metasploit_module', 'dangerous');
|
||||
ars.register(t);
|
||||
ars.setApprovalController(new ApprovalController({ preApprovedTools: ['metasploit_module'], onWarning }));
|
||||
const res = await ars.execute('metasploit_module', ctx({ module: 'exploit/x', target: '10.0.0.5' }));
|
||||
expect(res.success).toBe(true);
|
||||
expect(t.ran()).toBe(true);
|
||||
expect(onWarning).toHaveBeenCalledTimes(1);
|
||||
expect(onWarning.mock.calls[0][0].action).toContain('metasploit_module');
|
||||
});
|
||||
|
||||
it('ZERO-REGRESSION: pre-existing built-in tools stay UNGATED — only the new opt-in arsenal is gated', () => {
|
||||
// The approval gate covers the newly-armed dangerous arsenal (metasploit/hydra + Kali+ adapters,
|
||||
// stamped by post-ex.ts / the adapter factory). Built-in tools that ran freely before the gate
|
||||
// existed must keep that behavior — they carry NO riskTier, so isGatedRisk() never fires on them.
|
||||
const all = [...BUILTIN_TOOLS, ...EXTERNAL_TOOLS];
|
||||
const tier = (name: string): string | undefined => all.find((t) => t.name === name)?.riskTier;
|
||||
for (const n of ['sqli_scan', 'xss_scan', 'password_spray', 'hash_crack', 'dir_bruteforce']) {
|
||||
expect(tier(n), `${n} must stay ungated (zero regression)`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('an ungated tool is never gated even with a locked-down controller', async () => {
|
||||
const ars = new Arsenal();
|
||||
const t = probe('httpx_tool', 'active'); // active = ungated
|
||||
ars.register(t);
|
||||
ars.setApprovalController(new ApprovalController()); // fail-safe deny for gated tools only
|
||||
expect((await ars.execute('httpx_tool', ctx())).success).toBe(true);
|
||||
expect(t.ran()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opt-in built-in gating (T3MP3ST_GATE_BUILTINS) — stampSpicyBuiltin', () => {
|
||||
it('stamps ONLY the spicy built-ins with their tier, and never mutates the shared array', () => {
|
||||
// the shared BUILTIN_TOOLS stay ungated (default posture / zero regression) …
|
||||
for (const n of Object.keys(SPICY_BUILTIN_TIERS)) {
|
||||
expect(BUILTIN_TOOLS.find((t) => t.name === n)?.riskTier, `${n} shared def stays ungated`).toBeUndefined();
|
||||
}
|
||||
// … and stampSpicyBuiltin returns a tiered COPY for a spicy tool, the tool unchanged otherwise.
|
||||
for (const t of BUILTIN_TOOLS) {
|
||||
const stamped = stampSpicyBuiltin(t);
|
||||
const wantTier = SPICY_BUILTIN_TIERS[t.name];
|
||||
if (wantTier) {
|
||||
expect(stamped.riskTier).toBe(wantTier);
|
||||
expect(stamped).not.toBe(t); // fresh object — original untouched
|
||||
expect(t.riskTier).toBeUndefined();
|
||||
} else {
|
||||
expect(stamped).toBe(t); // ungated tools pass through by reference
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('a stamped spicy built-in IS fenced by the same fail-safe gate (password_spray → denied unattended)', async () => {
|
||||
const spray = BUILTIN_TOOLS.find((t) => t.name === 'password_spray');
|
||||
expect(spray, 'password_spray must exist in the arsenal').toBeTruthy();
|
||||
const gated = stampSpicyBuiltin(spray as CustomTool);
|
||||
expect(gated.riskTier).toBe('credential');
|
||||
const ars = new Arsenal();
|
||||
ars.register(gated);
|
||||
ars.setApprovalController(new ApprovalController()); // headless, no approver, no allowlist
|
||||
const res = await ars.execute('password_spray', ctx({ url: 'https://target.example.com/login' }));
|
||||
expect(res.success).toBe(false); // fail-safe deny — the opt-in gate now covers it
|
||||
expect(res.error).toMatch(/APPROVAL REQUIRED/);
|
||||
});
|
||||
|
||||
it('a stamped spicy built-in RUNS once pre-approved (approve-once / allowlist)', async () => {
|
||||
const t = probe('password_spray', 'credential'); // as stampSpicyBuiltin would produce
|
||||
const ars = new Arsenal();
|
||||
ars.register(t);
|
||||
ars.setApprovalController(new ApprovalController({ preApprovedTools: ['password_spray'] }));
|
||||
expect((await ars.execute('password_spray', ctx({ url: 'https://target.example.com/login' }))).success).toBe(true);
|
||||
expect(t.ran()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Egress scope gate — Phase-0 pack-hunt safety primitive.
|
||||
* arsenal.execute() must refuse a networked tool call whose target is outside the authorized
|
||||
* scope BEFORE the handler runs, so an LLM-supplied target can never reach an off-target host.
|
||||
* Pins: scopeViolation host math, and that execute() denies out-of-scope without invoking the handler.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Arsenal, scopeViolation, hostFromTargetValue, type ArsenalScope } from '../arsenal/index.js';
|
||||
import type { CustomTool, ToolContext } from '../types/index.js';
|
||||
|
||||
const scope: ArsenalScope = { allowedHosts: ['target.example.com'], allowLoopback: true, allowPrivate: true };
|
||||
const ctx = (parameters: Record<string, unknown>, target?: string): ToolContext =>
|
||||
({ parameters, ...(target ? { target: { id: 't', name: 't', type: 'web_application', address: target } as never } : {}) });
|
||||
|
||||
describe('egress scope gate', () => {
|
||||
it('extracts a bare host from urls / user@host:port / brackets', () => {
|
||||
expect(hostFromTargetValue('https://EVIL.com/admin?x=1')).toBe('evil.com');
|
||||
expect(hostFromTargetValue('root@10.0.0.5:22')).toBe('10.0.0.5');
|
||||
expect(hostFromTargetValue('scanme.example.com:443')).toBe('scanme.example.com');
|
||||
expect(hostFromTargetValue('not a host, just text')).toBe('not a host, just text'); // still lowered; scope decides
|
||||
expect(hostFromTargetValue(42)).toBeNull();
|
||||
});
|
||||
|
||||
it('allows authorized host + its subdomains, loopback, and private ranges', () => {
|
||||
expect(scopeViolation(scope, ctx({ url: 'https://target.example.com/x' }))).toBeNull();
|
||||
expect(scopeViolation(scope, ctx({ url: 'https://api.target.example.com/x' }))).toBeNull(); // subdomain
|
||||
expect(scopeViolation(scope, ctx({ host: '127.0.0.1' }))).toBeNull();
|
||||
expect(scopeViolation(scope, ctx({ target: '10.1.2.3' }))).toBeNull();
|
||||
expect(scopeViolation(scope, ctx({ domain: 'localhost' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks an out-of-scope host and reports it', () => {
|
||||
expect(scopeViolation(scope, ctx({ url: 'https://evil.com/x' }))).toBe('evil.com');
|
||||
expect(scopeViolation(scope, ctx({ target: '8.8.8.8' }))).toBe('8.8.8.8');
|
||||
// checks context.target.address too
|
||||
expect(scopeViolation(scope, ctx({}, 'http://attacker.net'))).toBe('attacker.net');
|
||||
});
|
||||
|
||||
it('FAILS CLOSED on a network-shaped but unparseable target (//host, file://, scheme) — no fail-open', () => {
|
||||
// these host-normalize to '' — must be treated as OUT of scope, not "no host = allowed"
|
||||
expect(scopeViolation(scope, ctx({ url: '//evil.com' }))).toBe('//evil.com');
|
||||
expect(scopeViolation(scope, ctx({ target: 'file:///etc/passwd' }))).toBe('file:///etc/passwd');
|
||||
expect(scopeViolation(scope, ctx({ rhosts: '//9.9.9.9' }))).toBe('//9.9.9.9'); // rhosts now covered too
|
||||
});
|
||||
|
||||
it('ZERO-REGRESSION: a plain absolute local path is NOT blocked (local-file tools run under a scope)', () => {
|
||||
// a path has no host and is not a network target — semgrep/trivy/slither scanning /code must pass
|
||||
expect(scopeViolation(scope, ctx({ target: '/code/src/app.py' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('validates a CIDR TARGET range, not just its slash-stripped base (mask-strip escape)', () => {
|
||||
// base 10.0.0.0 is private, but /0 would sweep the whole internet — block on the RANGE
|
||||
expect(scopeViolation(scope, ctx({ target: '10.0.0.0/0' }))).toBe('10.0.0.0/0');
|
||||
expect(scopeViolation(scope, ctx({ target: '192.168.0.0/8' }))).toBe('192.168.0.0/8'); // /8 base 192.168 spills out
|
||||
// an in-block private CIDR is still allowed (zero regression for legit internal scans)
|
||||
expect(scopeViolation(scope, ctx({ rhosts: '10.0.0.0/8' }))).toBeNull(); // exactly 10/8
|
||||
expect(scopeViolation(scope, ctx({ target: '10.1.2.0/24' }))).toBeNull(); // within 10/8
|
||||
// a URL path segment ending in /digits is NOT a CIDR mask
|
||||
expect(scopeViolation(scope, ctx({ url: 'https://target.example.com/api/12' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('a CIDR of an exactly-allowed host beyond /32 is out of scope', () => {
|
||||
const s2: ArsenalScope = { allowedHosts: ['203.0.113.10'], allowLoopback: false, allowPrivate: false };
|
||||
expect(scopeViolation(s2, ctx({ target: '203.0.113.10/8' }))).toBe('203.0.113.10/8');
|
||||
expect(scopeViolation(s2, ctx({ target: '203.0.113.10' }))).toBeNull(); // the host itself is fine
|
||||
});
|
||||
|
||||
it('no scope set = enforcement off (backward-compat)', () => {
|
||||
expect(scopeViolation(null, ctx({ url: 'https://evil.com' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('a tool with no target-like param is never gated', () => {
|
||||
expect(scopeViolation(scope, ctx({ hash: 'deadbeef', text: 'hello' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('execute() refuses an out-of-scope call WITHOUT running the handler', async () => {
|
||||
const ars = new Arsenal();
|
||||
let ran = false;
|
||||
const probe: CustomTool = {
|
||||
name: 'probe', description: 'x', category: 'web', parameters: [],
|
||||
handler: async () => { ran = true; return { success: true, output: 'hit' }; },
|
||||
};
|
||||
ars.register(probe);
|
||||
ars.setScope(scope);
|
||||
const denied = await ars.execute('probe', ctx({ url: 'https://evil.com' }));
|
||||
expect(ran).toBe(false);
|
||||
expect(denied.success).toBe(false);
|
||||
expect(denied.error).toMatch(/SCOPE DENIED/);
|
||||
// in-scope call runs the handler
|
||||
const ok = await ars.execute('probe', ctx({ url: 'https://target.example.com' }));
|
||||
expect(ran).toBe(true);
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Operator role toolkits — the per-operator tool allowlist (the swarm's specialization layer).
|
||||
* Each archetype's `defaultTools` is enforced at the AgentLoop via the arsenal name-allowlist
|
||||
* (getToolDefinitions(_, names)). Pins the four properties: real tools only, the gate exposes
|
||||
* exactly the toolkit, each operator has BROAD coverage, the swarm collectively covers every
|
||||
* tool, and overlap (same tool on several operators) is allowed.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Arsenal, BUILTIN_TOOLS, EXTERNAL_TOOLS } from '../arsenal/index.js';
|
||||
import { ARCHETYPE_PROFILES } from '../operators/index.js';
|
||||
|
||||
describe('Operator role toolkits — specialized · broad · full-coverage · overlap-OK', () => {
|
||||
const arsenal = new Arsenal();
|
||||
arsenal.registerMany(BUILTIN_TOOLS); // same population the mission does (src/index.ts)
|
||||
arsenal.registerMany(EXTERNAL_TOOLS);
|
||||
const allNames = arsenal.getToolDefinitions().map(t => t.name);
|
||||
const archetypes = Object.keys(ARCHETYPE_PROFILES) as (keyof typeof ARCHETYPE_PROFILES)[];
|
||||
|
||||
it('every operator toolkit contains only REAL arsenal tools (no phantoms)', () => {
|
||||
for (const a of archetypes) {
|
||||
const phantom = ARCHETYPE_PROFILES[a].defaultTools.filter(t => !allNames.includes(t));
|
||||
expect(phantom, `${a} references non-existent tools`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('the name-allowlist gate exposes EXACTLY the operator toolkit', () => {
|
||||
for (const a of archetypes) {
|
||||
const kit = ARCHETYPE_PROFILES[a].defaultTools;
|
||||
const exposed = arsenal.getToolDefinitions(undefined, kit).map(t => t.name).sort();
|
||||
expect(exposed, `${a} exposed tool set`).toEqual([...new Set(kit)].sort());
|
||||
}
|
||||
});
|
||||
|
||||
it('each operator has BROAD coverage (>= 12 tools)', () => {
|
||||
for (const a of archetypes) {
|
||||
expect(ARCHETYPE_PROFILES[a].defaultTools.length, `${a} toolkit size`).toBeGreaterThanOrEqual(12);
|
||||
}
|
||||
});
|
||||
|
||||
it('the swarm collectively covers EVERY arsenal tool', () => {
|
||||
const covered = new Set(archetypes.flatMap(a => ARCHETYPE_PROFILES[a].defaultTools));
|
||||
const uncovered = allNames.filter(n => !covered.has(n));
|
||||
expect(uncovered, 'tools no operator can reach').toEqual([]);
|
||||
});
|
||||
|
||||
it('overlap is allowed — generalist tools appear on multiple operators', () => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const a of archetypes) for (const t of ARCHETYPE_PROFILES[a].defaultTools) counts[t] = (counts[t] || 0) + 1;
|
||||
expect(counts['http_request'], 'http_request should be broadly shared').toBeGreaterThan(1);
|
||||
expect(counts['technology_detect'], 'technology_detect should be broadly shared').toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* PackBoard — pins the load-bearing invariants of the Phase-2 shared workspace
|
||||
* (the pack-hunt design + the red-team §2):
|
||||
* - de-dup collapses a duplicate post into an ENDORSEMENT that raises smoke (HT-1)
|
||||
* - claim() is an ATOMIC compare-and-set: two calls, same expectedVersion → exactly one grant (HT-1)
|
||||
* - an expired lease is reclaimable (the reaper backstop)
|
||||
* - refute() does NOT flip status by itself (HT-2)
|
||||
* - situationReport() is BOUNDED — never dumps the raw board, even with many leads
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PackBoard, dedupKey } from '../pack/board.js';
|
||||
import type { LeadInput } from '../pack/board.js';
|
||||
|
||||
// A deterministic, hand-cranked clock so lease/liveness math is exact in tests.
|
||||
function fakeClock(start = 1_000_000) {
|
||||
let t = start;
|
||||
const now = () => t;
|
||||
return { now, set: (v: number) => { t = v; }, advance: (dt: number) => { t += dt; } };
|
||||
}
|
||||
|
||||
const leadInput = (over: Partial<LeadInput> = {}): LeadInput => ({
|
||||
kind: 'lead',
|
||||
title: 'command injection in exec sink',
|
||||
where: { file: 'src/router.js', line: 42 },
|
||||
vulnClass: 'injection',
|
||||
confidence: 'high',
|
||||
provenance: 'tool',
|
||||
cwe: 'CWE-77',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('dedupKey — reuses cli-swarm formula', () => {
|
||||
it('near lines collapse, far lines are distinct (line bucket of 15)', () => {
|
||||
expect(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 100 }))
|
||||
.toBe(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 103 }));
|
||||
expect(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 100 }))
|
||||
.not.toBe(dedupKey({ file: 'lib/x.js', cwe: 'CWE-22', line: 580 }));
|
||||
});
|
||||
|
||||
it('distinct vuln classes at the same file:line-bucket do NOT collide (H11)', () => {
|
||||
// no CWE to disambiguate — vulnClass must keep an XSS and an SQLi a line apart distinct
|
||||
expect(dedupKey({ file: 'app.js', line: 10, vulnClass: 'xss' }))
|
||||
.not.toBe(dedupKey({ file: 'app.js', line: 12, vulnClass: 'sqli' }));
|
||||
});
|
||||
|
||||
it('coordinate-less url leads key on the surface, not one shared empty bucket (H13)', () => {
|
||||
expect(dedupKey({ url: 'https://x/a', vulnClass: 'ssrf' }))
|
||||
.not.toBe(dedupKey({ url: 'https://x/b', vulnClass: 'ssrf' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PackBoard — reference/evidence hardening (H12/H15/M5)', () => {
|
||||
it('getLead returns a SNAPSHOT — mutating it cannot corrupt the CAS version/status/claim', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('a', leadInput());
|
||||
const snap = board.getLead(lead.id)!;
|
||||
snap.version = 9999; // caller pokes the returned object
|
||||
snap.status = 'confirmed';
|
||||
snap.claim = { by: 'x', leaseUntil: 9e15, version: 9999 };
|
||||
const fresh = board.getLead(lead.id)!;
|
||||
expect(fresh.version).toBe(lead.version); // internal state untouched
|
||||
expect(fresh.status).toBe('open');
|
||||
expect(fresh.claim).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a duplicate with STRONGER provenance/severity monotonically upgrades the card (never downgrades)', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const weak = board.postLead('a', leadInput({ provenance: 'context', severity: 'low' }));
|
||||
board.postLead('b', leadInput({ provenance: 'tool', severity: 'high' })); // same dedupKey, stronger
|
||||
const up = board.getLead(weak.id)!;
|
||||
expect(up.provenance).toBe('tool');
|
||||
expect(up.severity).toBe('high');
|
||||
board.postLead('c', leadInput({ provenance: 'none', severity: 'info' })); // weaker dup
|
||||
const still = board.getLead(weak.id)!;
|
||||
expect(still.provenance).toBe('tool'); // not downgraded
|
||||
expect(still.severity).toBe('high');
|
||||
});
|
||||
|
||||
it('EVENT payloads are snapshots too — a subscriber mutating a lead:claimed payload cannot corrupt the CAS', () => {
|
||||
// the outbound accessors were hardened; the emit paths must match (7th-pass finding) or a
|
||||
// synchronous SSE/controller subscriber could flip status / reset the version / forge a lease.
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const posted = board.postLead('a', leadInput());
|
||||
board.on('lead:claimed', ({ lead }) => { lead.status = 'open'; lead.version = 0; delete lead.claim; });
|
||||
board.claim(posted.id, 'agentA', posted.version);
|
||||
const after = board.getLead(posted.id)!;
|
||||
expect(after.status).toBe('claimed'); // not reverted to open by the subscriber
|
||||
expect(after.version).toBe(1); // CAS token intact
|
||||
expect(after.claim?.by).toBe('agentA'); // lease intact
|
||||
});
|
||||
|
||||
it('INGEST clones `where` — mutating the caller-retained object after postLead cannot re-point the card', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const w = { file: 'safe.js', line: 10 };
|
||||
const snap = board.postLead('a', leadInput({ where: w }));
|
||||
w.file = 'attacker.js';
|
||||
(w as unknown as { url: string }).url = 'http://evil';
|
||||
const after = board.getLead(snap.id)!;
|
||||
expect(after.where.file).toBe('safe.js'); // internal projection immune to post-hoc mutation
|
||||
expect((after.where as { url?: string }).url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('postLead — de-dup collapses a duplicate into an endorsement (raises smoke)', () => {
|
||||
it('a second post with the same dedupKey does NOT create a new lead; it endorses and raises smoke', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const a = board.postLead('agent-a', leadInput());
|
||||
expect(a.smoke).toBe(1);
|
||||
expect(a.endorsements).toHaveLength(0);
|
||||
|
||||
// same file, a nearby line (same bucket), same CWE → same dedupKey → duplicate
|
||||
const b = board.postLead('agent-b', leadInput({ title: 'dup from B', where: { file: 'src/router.js', line: 45 } }));
|
||||
|
||||
expect(board.getAllLeads()).toHaveLength(1); // no second card
|
||||
expect(b.id).toBe(a.id); // returned the SAME lead
|
||||
expect(b.smoke).toBe(2); // smoke rose
|
||||
expect(b.endorsements).toHaveLength(1);
|
||||
expect(b.endorsements[0].by).toBe('agent-b');
|
||||
});
|
||||
|
||||
it('a genuinely different lead (different dedupKey) is a new card', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
board.postLead('agent-a', leadInput());
|
||||
board.postLead('agent-a', leadInput({ where: { file: 'src/other.js', line: 9 }, cwe: 'CWE-22' }));
|
||||
expect(board.getAllLeads()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claim — a real synchronous compare-and-set (atomic)', () => {
|
||||
it('two claims with the SAME expectedVersion → exactly one granted', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
const v = lead.version; // both racers see the same version
|
||||
|
||||
const first = board.claim(lead.id, 'agent-1', v);
|
||||
const second = board.claim(lead.id, 'agent-2', v);
|
||||
|
||||
const grants = [first.granted, second.granted].filter(Boolean);
|
||||
expect(grants).toHaveLength(1); // exactly one winner
|
||||
expect(first.granted).toBe(true); // FIFO on the single-threaded loop: first wins
|
||||
expect(second.granted).toBe(false); // stale expectedVersion (and lease held) → denied
|
||||
|
||||
const held = board.getLead(lead.id);
|
||||
expect(held?.claim?.by).toBe('agent-1');
|
||||
expect(held?.status).toBe('claimed');
|
||||
});
|
||||
|
||||
it('a stale expectedVersion is rejected and returns the current version', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
board.endorse('x', lead.id); // bumps version
|
||||
const res = board.claim(lead.id, 'agent-1', 0); // expected the pre-endorse version
|
||||
expect(res.granted).toBe(false);
|
||||
expect(res.version).toBe(board.getLead(lead.id)?.version);
|
||||
});
|
||||
|
||||
it('a quorum-terminal lead (refuted/confirmed/dead) is NOT claimable — sticky refutation (HT-2)', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
board.setLeadStatus('quorum', lead.id, 'refuted'); // the quorum killed it
|
||||
const res = board.claim(lead.id, 'agent-1', board.getLead(lead.id)!.version);
|
||||
expect(res.granted).toBe(false); // cannot be re-claimed back into active work
|
||||
expect(board.getLead(lead.id)?.status).toBe('refuted'); // not resurrected to 'claimed'
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseExpiredClaims — an expired lease is reclaimable', () => {
|
||||
it('reaps a lease past its leaseUntil and returns the lead to open, then another agent can claim it', () => {
|
||||
const clock = fakeClock();
|
||||
const board = new PackBoard({}, clock.now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
|
||||
const c = board.claim(lead.id, 'agent-1', lead.version, 1000);
|
||||
expect(c.granted).toBe(true);
|
||||
|
||||
// before expiry: agent-2 cannot claim (live lease held)
|
||||
expect(board.claim(lead.id, 'agent-2', c.version).granted).toBe(false);
|
||||
|
||||
clock.advance(1001); // lease window elapses
|
||||
const released = board.releaseExpiredClaims();
|
||||
expect(released).toContain(lead.id);
|
||||
|
||||
const reopened = board.getLead(lead.id);
|
||||
expect(reopened?.claim).toBeUndefined();
|
||||
expect(reopened?.status).toBe('open');
|
||||
|
||||
// now agent-2 CAN claim it with the current version
|
||||
const retry = board.claim(lead.id, 'agent-2', reopened?.version ?? -1);
|
||||
expect(retry.granted).toBe(true);
|
||||
expect(board.getLead(lead.id)?.claim?.by).toBe('agent-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refute — does NOT flip status by itself (HT-2)', () => {
|
||||
it('appends a refutation vote but leaves status unchanged; only the quorum flips it', () => {
|
||||
const board = new PackBoard({}, fakeClock().now);
|
||||
const lead = board.postLead('poster', leadInput());
|
||||
expect(lead.status).toBe('open');
|
||||
|
||||
const r = board.refute('auditor', lead.id, 'claims a startsWith guard', 'src/router.js:12');
|
||||
expect(r?.refutations).toHaveLength(1);
|
||||
expect(r?.refutations[0].guard).toBe('src/router.js:12');
|
||||
expect(r?.status).toBe('open'); // STILL open — refute() is a signal, not a verdict
|
||||
|
||||
// even many refutations don't flip it
|
||||
board.refute('auditor-2', lead.id, 'agrees');
|
||||
board.refute('auditor-3', lead.id, 'agrees');
|
||||
expect(board.getLead(lead.id)?.status).toBe('open');
|
||||
|
||||
// only the explicit quorum path changes status
|
||||
board.setLeadStatus('quorum', lead.id, 'refuted');
|
||||
expect(board.getLead(lead.id)?.status).toBe('refuted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('situationReport — bounded, never dumps the raw board', () => {
|
||||
it('stays under the char cap even with far more leads than the cap could hold verbatim', () => {
|
||||
const cap = 1200;
|
||||
const board = new PackBoard({ reportCharCap: cap, defaultMaxLeads: 8 }, fakeClock().now);
|
||||
|
||||
// flood the board with 500 distinct leads (distinct files → distinct dedupKeys)
|
||||
for (let i = 0; i < 500; i++) {
|
||||
board.postLead(`agent-${i % 7}`, leadInput({
|
||||
title: `finding number ${i} with a deliberately long descriptive title `.repeat(3),
|
||||
where: { file: `src/module_${i}.js`, line: (i * 31) % 400 },
|
||||
cwe: `CWE-${i % 90}`,
|
||||
}));
|
||||
}
|
||||
for (let i = 0; i < 20; i++) board.heartbeat(`agent-${i}`, 'hunting', 'x'.repeat(50));
|
||||
|
||||
const report = board.situationReport('agent-0', { maxLeads: 8 });
|
||||
expect(report.length).toBeLessThanOrEqual(cap);
|
||||
expect(report).toContain('PACK BOARD');
|
||||
});
|
||||
|
||||
it('ranks unclaimed leads by smoke and excludes leads claimed by others / includes your own', () => {
|
||||
const clock = fakeClock();
|
||||
const board = new PackBoard({ reportCharCap: 4000 }, clock.now);
|
||||
|
||||
const hot = board.postLead('a', leadInput({ title: 'HOT lead', where: { file: 'src/hot.js', line: 1 }, cwe: 'CWE-1' }));
|
||||
board.endorse('b', hot.id);
|
||||
board.endorse('c', hot.id); // smoke = 3
|
||||
|
||||
const cold = board.postLead('a', leadInput({ title: 'cold lead', where: { file: 'src/cold.js', line: 1 }, cwe: 'CWE-2' }));
|
||||
|
||||
// a lead claimed by SOMEONE ELSE should not appear in the open-leads section
|
||||
const other = board.postLead('a', leadInput({ title: 'owned by z', where: { file: 'src/z.js', line: 1 }, cwe: 'CWE-3' }));
|
||||
board.claim(other.id, 'agent-z', other.version);
|
||||
|
||||
// a lead the reader claims → should show under YOUR CLAIMS
|
||||
const own = board.postLead('a', leadInput({ title: 'my own work', where: { file: 'src/mine.js', line: 1 }, cwe: 'CWE-4' }));
|
||||
board.claim(own.id, 'reader', own.version);
|
||||
|
||||
board.heartbeat('reader', 'hunting');
|
||||
const report = board.situationReport('reader', { maxLeads: 8 });
|
||||
|
||||
expect(report).toContain('HOT lead');
|
||||
expect(report).toContain('cold lead');
|
||||
expect(report.indexOf('HOT lead')).toBeLessThan(report.indexOf('cold lead')); // smoke ranking
|
||||
expect(report).not.toContain('owned by z'); // claimed by another agent → hidden from open leads
|
||||
expect(report).toContain('YOUR CLAIMS');
|
||||
expect(report).toContain('my own work');
|
||||
expect(cold.smoke).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Rate-limit / degraded-health + LLM semaphore — Phase-0 pack-hunt safety primitive (HT-3).
|
||||
* A rate-limited slot must NEVER read as "clean / found-nothing", and a single global semaphore
|
||||
* must cap ALL local-agent calls. Pins: isRateLimited on real quota strings, the 0.5 degraded
|
||||
* threshold, and that the Semaphore never exceeds max concurrent holders.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isRateLimited, DegradedTracker, Semaphore } from '../pack/health.js';
|
||||
|
||||
describe('isRateLimited', () => {
|
||||
it('catches real usage-limit / 429 / quota signatures', () => {
|
||||
// real Claude/Anthropic banner
|
||||
expect(isRateLimited("ERROR: You've hit your usage limit. try again at 12:10 PM")).toBe(true);
|
||||
expect(isRateLimited('Claude: you have hit your usage limit')).toBe(true);
|
||||
expect(isRateLimited('429 Too Many Requests')).toBe(true);
|
||||
expect(isRateLimited('HTTP 429')).toBe(true);
|
||||
expect(isRateLimited('rate limit reached for this model')).toBe(true);
|
||||
expect(isRateLimited('rate-limited')).toBe(true);
|
||||
expect(isRateLimited('quota exceeded')).toBe(true);
|
||||
expect(isRateLimited('{"error":{"code":"insufficient_quota"}}')).toBe(true);
|
||||
expect(isRateLimited('Too Many Requests')).toBe(true);
|
||||
});
|
||||
|
||||
it('catches provider-specific throttle signals the original regex missed', () => {
|
||||
expect(isRateLimited('Error: Overloaded (overloaded_error)')).toBe(true); // Anthropic 529
|
||||
expect(isRateLimited('HTTP 529: overloaded')).toBe(true);
|
||||
expect(isRateLimited('TooManyRequestsException: Rate exceeded')).toBe(true); // AWS Bedrock
|
||||
expect(isRateLimited('grpc status RESOURCE_EXHAUSTED')).toBe(true); // gRPC / Vertex
|
||||
expect(isRateLimited('You exceeded your current quota, please check your plan')).toBe(true); // OpenAI reversed
|
||||
expect(isRateLimited('request was throttled, retry-after 30s')).toBe(true);
|
||||
expect(isRateLimited('503 Service Unavailable')).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT false-positive on a bare integer that merely looks like a status code', () => {
|
||||
expect(isRateLimited('see line 429 of the file')).toBe(false);
|
||||
expect(isRateLimited('CWE-529: exposure of sensitive information')).toBe(false);
|
||||
expect(isRateLimited('found 4290 candidate sinks')).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT false-positive on benign prose that incidentally contains throttle vocabulary', () => {
|
||||
// the signals must be the SPECIFIC provider tokens, not bare words in finding narratives
|
||||
expect(isRateLimited('resource exhausted state in the parser')).toBe(false); // not RESOURCE_EXHAUSTED
|
||||
expect(isRateLimited('a note about an overloaded operator')).toBe(false); // not overloaded_error
|
||||
expect(isRateLimited('the quota management module')).toBe(false); // not "quota exceeded"
|
||||
expect(isRateLimited('capacity planning document')).toBe(false); // not "at capacity"
|
||||
expect(isRateLimited('opened the throttle valve')).toBe(false); // "throttle" (noun) != "throttled"/"throttling"
|
||||
expect(isRateLimited('disk quota for the container')).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT flag a rate-limit / DoS FINDING body as a throttled slot (finding vs throttle)', () => {
|
||||
// a pack agent's OWN result describing a rate-limit/DoS vuln must read as a FINDING, not degraded
|
||||
expect(isRateLimited('Found: rate limiting is MISSING on /login (CWE-307)')).toBe(false);
|
||||
expect(isRateLimited('no rate-limit on password reset — brute force possible')).toBe(false);
|
||||
expect(isRateLimited('CWE-400: uncontrolled resource consumption leads to service unavailable')).toBe(false);
|
||||
expect(isRateLimited('recommend: slow down the attacker via a captcha')).toBe(false);
|
||||
expect(isRateLimited('concurrent request handling bug (race)')).toBe(false);
|
||||
expect(isRateLimited('the endpoint enforces no usage limit at all')).toBe(false);
|
||||
// but a limit actually being HIT (a genuine throttle) is still caught
|
||||
expect(isRateLimited('rate limit exceeded')).toBe(true);
|
||||
expect(isRateLimited('rate-limited')).toBe(true);
|
||||
});
|
||||
|
||||
it('still catches the canonical provider throttles the tightening must not lose (8th-pass regression)', () => {
|
||||
expect(isRateLimited('{"type":"error","error":{"type":"rate_limit_error"}}')).toBe(true); // Anthropic 429 body
|
||||
expect(isRateLimited('exceeded your per-minute rate limit')).toBe(true); // qualifier between verb and "rate limit"
|
||||
expect(isRateLimited('usage limit reached')).toBe(true); // Claude subscription banner (verb after)
|
||||
expect(isRateLimited("You've reached your usage limit")).toBe(true); // verb before
|
||||
expect(isRateLimited('Message limit reached')).toBe(true);
|
||||
// and the finding-vs-throttle discrimination still holds
|
||||
expect(isRateLimited('the endpoint enforces no usage limit at all')).toBe(false);
|
||||
expect(isRateLimited('rate limiting is MISSING on /login')).toBe(false);
|
||||
});
|
||||
|
||||
it('catches the provider throttles the tightening MUST NOT lose (9th-pass false-negatives)', () => {
|
||||
expect(isRateLimited('You have exceeded a secondary rate limit')).toBe(true); // GitHub/GitLab
|
||||
expect(isRateLimited('ServiceQuotaExceededException: rate exceeded')).toBe(true); // AWS Bedrock
|
||||
expect(isRateLimited('The engine is currently overloaded, please try again')).toBe(true); // OpenAI/Anthropic 529 prose
|
||||
expect(isRateLimited('Overloaded')).toBe(true); // Anthropic 529 bare body
|
||||
expect(isRateLimited('grpc error: Resource has been exhausted (e.g. check quota)')).toBe(true); // gRPC/Vertex prose
|
||||
// still NOT a false-positive on benign code-review vocabulary
|
||||
expect(isRateLimited('a note about an overloaded operator')).toBe(false);
|
||||
expect(isRateLimited('resource exhausted state in the parser')).toBe(false);
|
||||
});
|
||||
|
||||
it('catches the underscore 429 enum and future-tense throttle bodies (11th-pass false-negatives)', () => {
|
||||
expect(isRateLimited('{"reason":"too_many_requests"}')).toBe(true); // HTTP 429 reason enum
|
||||
expect(isRateLimited('TOO_MANY_REQUESTS')).toBe(true);
|
||||
expect(isRateLimited("This request would exceed your organization's rate limit")).toBe(true); // Anthropic future-tense
|
||||
expect(isRateLimited('you will exceed your per-minute rate limit')).toBe(true);
|
||||
// and still not a finding body
|
||||
expect(isRateLimited('rate limiting is MISSING on /login')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores normal findings output and empty/undefined text', () => {
|
||||
expect(isRateLimited('[{"title":"x","file":"a.py","line":5,"cwe":"CWE-22"}]')).toBe(false);
|
||||
expect(isRateLimited('Clean — nothing exploitable in my specialty')).toBe(false);
|
||||
expect(isRateLimited('')).toBe(false);
|
||||
// guards against undefined leaking in despite the string type (matches cli-swarm's String(x||'') coercion)
|
||||
expect(isRateLimited(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DegradedTracker — 0.5 threshold', () => {
|
||||
it('is OK when all calls succeed', () => {
|
||||
const t = new DegradedTracker();
|
||||
t.record(true);
|
||||
t.record(true);
|
||||
t.record(true);
|
||||
expect(t.calls).toBe(3);
|
||||
expect(t.fails).toBe(0);
|
||||
expect(t.verdict()).toBe('OK');
|
||||
});
|
||||
|
||||
it('stays OK just below the threshold (2 of 5 failed = 0.4)', () => {
|
||||
const t = new DegradedTracker();
|
||||
[true, true, true, false, false].forEach((ok) => t.record(ok));
|
||||
expect(t.failRatio()).toBeCloseTo(0.4);
|
||||
expect(t.verdict()).toBe('OK');
|
||||
});
|
||||
|
||||
it('flips to INVALID_DEGRADED exactly AT the 0.5 threshold (2 of 4 failed)', () => {
|
||||
const t = new DegradedTracker();
|
||||
[true, true, false, false].forEach((ok) => t.record(ok));
|
||||
expect(t.failRatio()).toBe(0.5);
|
||||
expect(t.verdict()).toBe('INVALID_DEGRADED');
|
||||
});
|
||||
|
||||
it('is INVALID_DEGRADED when failures dominate', () => {
|
||||
const t = new DegradedTracker();
|
||||
[false, false, false, true].forEach((ok) => t.record(ok));
|
||||
expect(t.verdict()).toBe('INVALID_DEGRADED');
|
||||
});
|
||||
|
||||
it('a rate-limited slot poisons the verdict — never clean', () => {
|
||||
const t = new DegradedTracker();
|
||||
// two slots came back "successfully" but one is actually a quota banner
|
||||
t.recordResult(true, 'Clean — no findings');
|
||||
t.recordResult(true, "You've hit your usage limit");
|
||||
// the quota banner is booked as a failure => 1/2 = 0.5 => degraded
|
||||
expect(t.fails).toBe(1);
|
||||
expect(t.verdict()).toBe('INVALID_DEGRADED');
|
||||
});
|
||||
|
||||
it('an empty run (no calls) is INVALID_DEGRADED, not clean', () => {
|
||||
const t = new DegradedTracker();
|
||||
expect(t.verdict()).toBe('INVALID_DEGRADED');
|
||||
expect(t.failRatio()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Semaphore', () => {
|
||||
it('rejects a non-positive max', () => {
|
||||
expect(() => new Semaphore(0)).toThrow(RangeError);
|
||||
expect(() => new Semaphore(-1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('never exceeds max concurrent holders under a burst', async () => {
|
||||
const MAX = 3;
|
||||
const N = 40;
|
||||
const sem = new Semaphore(MAX);
|
||||
let live = 0;
|
||||
let peak = 0;
|
||||
|
||||
const task = async (): Promise<void> => {
|
||||
const release = await sem.acquire();
|
||||
live += 1;
|
||||
peak = Math.max(peak, live);
|
||||
expect(live).toBeLessThanOrEqual(MAX); // invariant checked WHILE holding
|
||||
// yield across a macrotask so overlap is real, not artificial
|
||||
await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 5)));
|
||||
live -= 1;
|
||||
release();
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: N }, task));
|
||||
expect(peak).toBe(MAX); // saturates (N >> MAX) but never over
|
||||
expect(live).toBe(0);
|
||||
expect(sem.inFlight).toBe(0);
|
||||
expect(sem.queued).toBe(0);
|
||||
});
|
||||
|
||||
it('serializes fully at max=1', async () => {
|
||||
const sem = new Semaphore(1);
|
||||
let live = 0;
|
||||
let peak = 0;
|
||||
const task = async (): Promise<void> => {
|
||||
const release = await sem.acquire();
|
||||
live += 1;
|
||||
peak = Math.max(peak, live);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
live -= 1;
|
||||
release();
|
||||
};
|
||||
await Promise.all([task(), task(), task(), task()]);
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
|
||||
it('serves waiters FIFO', async () => {
|
||||
const sem = new Semaphore(1);
|
||||
const order: number[] = [];
|
||||
const first = await sem.acquire(); // occupy the only slot
|
||||
// three callers queue up in order
|
||||
const p2 = sem.acquire().then((rel) => { order.push(2); rel(); });
|
||||
const p3 = sem.acquire().then((rel) => { order.push(3); rel(); });
|
||||
const p4 = sem.acquire().then((rel) => { order.push(4); rel(); });
|
||||
expect(sem.queued).toBe(3);
|
||||
first(); // release => drains in enqueue order
|
||||
await Promise.all([p2, p3, p4]);
|
||||
expect(order).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it('double-release is a no-op (cannot inflate the pool past max)', async () => {
|
||||
const sem = new Semaphore(1);
|
||||
const release = await sem.acquire();
|
||||
release();
|
||||
release(); // second call must NOT free a phantom extra slot
|
||||
|
||||
let live = 0;
|
||||
let peak = 0;
|
||||
const task = async (): Promise<void> => {
|
||||
const rel = await sem.acquire();
|
||||
live += 1;
|
||||
peak = Math.max(peak, live);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
live -= 1;
|
||||
rel();
|
||||
};
|
||||
await Promise.all([task(), task(), task()]);
|
||||
expect(peak).toBe(1); // still fully serialized
|
||||
});
|
||||
|
||||
it('run() releases even when the body throws', async () => {
|
||||
const sem = new Semaphore(1);
|
||||
await expect(sem.run(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
|
||||
expect(sem.inFlight).toBe(0);
|
||||
// slot is free again — a subsequent acquire resolves immediately
|
||||
const release = await sem.acquire();
|
||||
expect(sem.inFlight).toBe(1);
|
||||
release();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Purpose-built post-ex drivers (metasploit, hydra). Pins the load-bearing safety contract:
|
||||
* - each carries its riskTier (dangerous / credential) so the approval gate fences it.
|
||||
* - a missing binary DEGRADES (does not throw).
|
||||
* - the target is scope-checked AND dash-guarded before anything spawns.
|
||||
* - metasploit refuses msf-command injection (;, newline, quotes) in module/options.
|
||||
* - the happy path builds the expected argv; hydra caps its task rate.
|
||||
* All deps are injected fakes — no real msfconsole/hydra is spawned.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createMetasploitTool, createHydraTool, buildPostExTools } from '../arsenal/post-ex.js';
|
||||
import type { AdapterToolDeps, SubprocessResult } from '../arsenal/adapter-tools.js';
|
||||
import type { ToolContext } from '../types/index.js';
|
||||
|
||||
function makeDeps(overrides: Partial<AdapterToolDeps> = {}): AdapterToolDeps & { spawns: Array<{ cmd: string; args: string[] }> } {
|
||||
const spawns: Array<{ cmd: string; args: string[] }> = [];
|
||||
return {
|
||||
spawns,
|
||||
isToolAvailable: async () => true,
|
||||
runSubprocess: async (cmd, args): Promise<SubprocessResult> => {
|
||||
spawns.push({ cmd, args });
|
||||
return { stdout: 'FAKE', stderr: '', exitCode: 0 };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const ctx = (parameters: Record<string, unknown>): ToolContext => ({ parameters });
|
||||
|
||||
describe('metasploit_module driver', () => {
|
||||
it('is a dangerous, approval-gated tool', () => {
|
||||
expect(createMetasploitTool(makeDeps()).riskTier).toBe('dangerous');
|
||||
});
|
||||
|
||||
it('degrades (no throw) when msfconsole is absent', async () => {
|
||||
const deps = makeDeps({ isToolAvailable: async () => false });
|
||||
const res = await createMetasploitTool(deps).handler(ctx({ module: 'auxiliary/scanner/ssh/ssh_version', target: '10.0.0.5' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/not installed/);
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses an out-of-scope RHOSTS before spawning', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => false });
|
||||
const res = await createMetasploitTool(deps).handler(ctx({ module: 'exploit/x', target: 'evil.example.com' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/SCOPE DENIED/);
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses msf-command injection in a module/option value', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = createMetasploitTool(deps);
|
||||
const inj = await tool.handler(ctx({ module: 'exploit/x; sessions -K', target: '10.0.0.5' }));
|
||||
expect(inj.success).toBe(false);
|
||||
expect(inj.error).toMatch(/invalid module|msf-command metacharacters/); // caught by the module-path validator
|
||||
const inj2 = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { LHOST: '1.2.3.4\nexploit -j' } }));
|
||||
expect(inj2.success).toBe(false);
|
||||
expect(inj2.error).toMatch(/msf-command metacharacters/); // an option VALUE newline is caught by MSF_UNSAFE
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a target-override via options (RHOSTS/RHOST/VHOST) — no out-of-scope escape', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true }); // the resolved target is in-scope
|
||||
const tool = createMetasploitTool(deps);
|
||||
const res = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { RHOSTS: '9.9.9.9' } }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/target override refused/);
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a whitespace-padded / space-bearing option key (RHOSTS-override injection escape)', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true }); // resolved target is in-scope
|
||||
const tool = createMetasploitTool(deps);
|
||||
// each of these keys would inject a second `set …` directive into the -x string if it slipped
|
||||
// past the exact-match target guard; the strict key validator must refuse them all.
|
||||
for (const key of ['RHOSTS ', ' rhosts', 'RHOSTS\t', 'RHOSTS 9.9.9.9', 'AutoRunScript multi/handler']) {
|
||||
const res = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { [key]: 'y' } }));
|
||||
expect(res.success, `key "${key}" must be refused`).toBe(false);
|
||||
}
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
// a clean option key still works
|
||||
const ok = await tool.handler(ctx({ module: 'exploit/x', target: '10.0.0.5', options: { RPORT: 8080 } }));
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a malformed // , scheme, or @ target (checked host != emitted RHOSTS)', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
// '8.8.8.8@10.0.0.5' scope-checks as 10.0.0.5 but msf would receive the raw @-value — refuse it
|
||||
for (const t of ['//evil.com', 'file:///etc/passwd', '8.8.8.8@10.0.0.5']) {
|
||||
const res = await createMetasploitTool(deps).handler(ctx({ module: 'exploit/x', target: t }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/malformed target/);
|
||||
}
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('builds the resource-command -x string on the happy path', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const res = await createMetasploitTool(deps).handler(
|
||||
ctx({ module: 'auxiliary/scanner/ssh/ssh_login', target: '10.0.0.5', options: { RPORT: 22 } }),
|
||||
);
|
||||
expect(res.success).toBe(true);
|
||||
expect(deps.spawns[0].cmd).toBe('msfconsole');
|
||||
const cmd = deps.spawns[0].args[deps.spawns[0].args.indexOf('-x') + 1];
|
||||
expect(cmd).toContain('use auxiliary/scanner/ssh/ssh_login');
|
||||
expect(cmd).toContain('set RHOSTS 10.0.0.5');
|
||||
expect(cmd).toContain('set RPORT 22');
|
||||
expect(cmd).toContain('run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydra_bruteforce driver', () => {
|
||||
it('is a credential, approval-gated tool', () => {
|
||||
expect(createHydraTool(makeDeps()).riskTier).toBe('credential');
|
||||
});
|
||||
|
||||
it('degrades when hydra is absent', async () => {
|
||||
const deps = makeDeps({ isToolAvailable: async () => false });
|
||||
const res = await createHydraTool(deps).handler(ctx({ service: 'ssh', target: '10.0.0.5', user: 'root', password: 'x' }));
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/not installed/);
|
||||
});
|
||||
|
||||
it('rejects a bogus service and option-looking credentials', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
const tool = createHydraTool(deps);
|
||||
expect((await tool.handler(ctx({ service: 'ssh; rm -rf', target: '10.0.0.5', user: 'a', password: 'b' }))).success).toBe(false);
|
||||
expect((await tool.handler(ctx({ service: 'ssh', target: '10.0.0.5', user: '-oN', password: 'b' }))).success).toBe(false);
|
||||
expect((await tool.handler(ctx({ service: 'ssh', target: '//evil.com', user: 'a', password: 'b' }))).success).toBe(false); // malformed target
|
||||
expect(deps.spawns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('scope-denies the target and requires user+pass', async () => {
|
||||
expect((await createHydraTool(makeDeps({ scopeOk: () => false })).handler(ctx({ service: 'ssh', target: '8.8.8.8', user: 'a', password: 'b' }))).success).toBe(false);
|
||||
expect((await createHydraTool(makeDeps({ scopeOk: () => true })).handler(ctx({ service: 'ssh', target: '10.0.0.5' }))).error).toMatch(/user/);
|
||||
});
|
||||
|
||||
it('builds argv and caps the task rate at 16', async () => {
|
||||
const deps = makeDeps({ scopeOk: () => true });
|
||||
await createHydraTool(deps).handler(ctx({ service: 'ssh', target: '10.0.0.5', userlist: '/u.txt', passlist: '/p.txt', tasks: 500 }));
|
||||
const argv = deps.spawns[0].args;
|
||||
expect(argv).toEqual(['-L', '/u.txt', '-P', '/p.txt', '-t', '16', '-f', 'ssh://10.0.0.5']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPostExTools', () => {
|
||||
it('returns both drivers, both approval-gated', () => {
|
||||
const tools = buildPostExTools(makeDeps());
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(['hydra_bruteforce', 'metasploit_module']);
|
||||
expect(tools.every((t) => t.riskTier === 'dangerous' || t.riskTier === 'credential')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Secret / credential redaction (server response + audit + SSE boundary). Pins the load-bearing scrub,
|
||||
* including the 7th-pass fix: a `scheme://user:pass@host` login URL (the shape approval audit target/
|
||||
* action strings take for a gated credential tool) must not leak its password over the REST endpoint or
|
||||
* the SSE feed.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { redactString, redactSecrets } from '../redact.js';
|
||||
|
||||
describe('redactString — URL userinfo (basic-auth) scrub', () => {
|
||||
it('strips the password from a scheme://user:pass@host login URL (all schemes)', () => {
|
||||
expect(redactString('https://admin:hunter2@vpn.corp/login')).toBe('https://[redacted]@vpn.corp/login');
|
||||
expect(redactString('ssh://root:toor@10.0.0.5')).toBe('ssh://[redacted]@10.0.0.5');
|
||||
expect(redactString('ftp://svc:S3cr3t@files.internal/x')).toBe('ftp://[redacted]@files.internal/x');
|
||||
});
|
||||
|
||||
it('covers a password that itself contains @ (redacts up to the LAST @ before the host)', () => {
|
||||
expect(redactString('https://svcacct:S3cr3t-P@ss!23@intranet.corp/login'))
|
||||
.toBe('https://[redacted]@intranet.corp/login');
|
||||
});
|
||||
|
||||
it('leaves credential-free URLs and non-authority URIs untouched', () => {
|
||||
expect(redactString('https://example.com/path?x=1')).toBe('https://example.com/path?x=1');
|
||||
expect(redactString('visit https://host/a@b/c for docs')).toBe('visit https://host/a@b/c for docs'); // @ after a path segment, not userinfo
|
||||
expect(redactString('mailto:alice@example.com')).toBe('mailto:alice@example.com'); // no ://, not userinfo
|
||||
});
|
||||
|
||||
it('still redacts the pre-existing secret shapes (regression)', () => {
|
||||
expect(redactString('key AKIAIOSFODNN7EXAMPLE here')).toContain('[redacted]');
|
||||
expect(redactString('token=abc123def456ghi')).toBe('token=[redacted]');
|
||||
expect(redactString('Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')).toContain('Bearer [redacted]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactSecrets — approval-audit record shape', () => {
|
||||
it('scrubs a userinfo password in the audit target/action a gated credential tool would record', () => {
|
||||
// mirrors what Arsenal.execute() records for password_spray({ url: 'https://user:pass@host/login' })
|
||||
const record = {
|
||||
tool: 'password_spray',
|
||||
risk: 'credential',
|
||||
target: 'https://admin:hunter2@vpn.corp/login',
|
||||
action: 'password_spray (url=https://admin:hunter2@vpn.corp/login username=admin) → https://admin:hunter2@vpn.corp/login',
|
||||
outcome: 'allowed-preapproved',
|
||||
};
|
||||
const out = redactSecrets(record) as typeof record;
|
||||
expect(out.target).toBe('https://[redacted]@vpn.corp/login');
|
||||
expect(out.action).not.toContain('hunter2');
|
||||
expect(out.action).toContain('[redacted]');
|
||||
});
|
||||
|
||||
it('redacts a key named like a secret and recurses through arrays/objects', () => {
|
||||
const out = redactSecrets({ password: 'p', nested: [{ token: 't', url: 'https://u:pw@h/x' }] }) as {
|
||||
password: string; nested: Array<{ token: string; url: string }>;
|
||||
};
|
||||
expect(out.password).toBe('[redacted]');
|
||||
expect(out.nested[0].token).toBe('[redacted]');
|
||||
expect(out.nested[0].url).toBe('https://[redacted]@h/x');
|
||||
});
|
||||
});
|
||||
+11
-3
@@ -31,6 +31,8 @@ export interface AgentLoopOptions {
|
||||
maxTokens?: number;
|
||||
/** Tool categories to expose (default: all) */
|
||||
toolCategories?: string[];
|
||||
/** Explicit tool-NAME allowlist — the operator's role toolkit. Overrides toolCategories. */
|
||||
tools?: string[];
|
||||
/** Whether to include detailed tool output in context (default: true) */
|
||||
verboseToolOutput?: boolean;
|
||||
/** Max characters per tool result before truncation (default: 4000) */
|
||||
@@ -91,6 +93,7 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
maxIterations: options?.maxIterations ?? 15,
|
||||
maxTokens: options?.maxTokens ?? 50000,
|
||||
toolCategories: options?.toolCategories ?? [],
|
||||
tools: options?.tools ?? [],
|
||||
verboseToolOutput: options?.verboseToolOutput ?? true,
|
||||
maxToolOutputLength: options?.maxToolOutputLength ?? 4000,
|
||||
};
|
||||
@@ -114,9 +117,11 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
const seenCalls = new Map<string, string>();
|
||||
let noProgress = 0;
|
||||
|
||||
// Get tool definitions from Arsenal
|
||||
// Get tool definitions from Arsenal (the operator's role toolkit: name allowlist wins,
|
||||
// then category filter, then all).
|
||||
const toolDefs = this.arsenal.getToolDefinitions(
|
||||
this.options.toolCategories.length > 0 ? this.options.toolCategories : undefined
|
||||
this.options.toolCategories.length > 0 ? this.options.toolCategories : undefined,
|
||||
this.options.tools.length > 0 ? this.options.tools : undefined
|
||||
);
|
||||
|
||||
// Build initial messages
|
||||
@@ -308,7 +313,10 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
|
||||
// A bad/hallucinated tool name must NOT crash the loop. Return the callable set so the
|
||||
// model self-corrects in-place instead of dying or looping on a tool that doesn't exist.
|
||||
const available = this.arsenal
|
||||
.getToolDefinitions(this.options.toolCategories.length ? this.options.toolCategories : undefined)
|
||||
.getToolDefinitions(
|
||||
this.options.toolCategories.length ? this.options.toolCategories : undefined,
|
||||
this.options.tools.length ? this.options.tools : undefined
|
||||
)
|
||||
.map((t) => t.name);
|
||||
toolResult = {
|
||||
success: false,
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* T3MP3ST Arsenal — Kali+ adapter → CustomTool factory (Phase-1)
|
||||
*
|
||||
* Turns the catalogued Kali+ `ToolAdapter`s (src/arsenal/catalog.ts) into real, arg-templated,
|
||||
* scope-checked, gracefully-degrading `CustomTool`s (src/types/index.ts) that the engine's Arsenal
|
||||
* can register and an operator can call.
|
||||
*
|
||||
* Design constraints (from the pack-hunt design HT-1):
|
||||
* - Dangerous adapters stay off the callable surface: `execution === 'catalog_only'` (metasploit,
|
||||
* hydra) and `execution === 'import_only'` (bloodhound) are NEVER minted — `adapterToCustomTool`
|
||||
* returns `null` for them, so a keyless pack agent cannot invoke them through generic execution.
|
||||
* - Missing binary DEGRADES, never throws: the handler returns `{ success:false, error: installHint }`
|
||||
* exactly like the hand-written `EXTERNAL_TOOLS` (nmap_scan/nuclei_scan) do, so the model can pick
|
||||
* another tool instead of crashing the loop.
|
||||
* - Scope is enforced BEFORE the subprocess runs. The Arsenal already has a hard egress gate in
|
||||
* `execute()` (scopeViolation), but that only fires when a mission has set a scope. This factory
|
||||
* accepts an OPTIONAL `scopeOk(target)` predicate as a second, in-handler belt-and-braces check on
|
||||
* the resolved target for THIS specific adapter; when provided and it returns false the handler
|
||||
* returns a `SCOPE DENIED` result without spawning anything.
|
||||
*
|
||||
* Everything is done through INJECTED dependencies (`runSubprocess` / `isToolAvailable` from
|
||||
* src/arsenal/index.ts, an optional `scopeOk`) so this module stays self-contained and unit-testable
|
||||
* with fakes — it spawns no real binaries of its own and imports no server code.
|
||||
*/
|
||||
|
||||
import type { ToolAdapter } from './catalog.js';
|
||||
import type { CustomTool, ToolContext, ToolResult } from '../types/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// INJECTED DEPENDENCIES
|
||||
// =============================================================================
|
||||
|
||||
/** Result shape of the real `runSubprocess` in src/arsenal/index.ts. */
|
||||
export interface SubprocessResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The (fakeable) dependencies the factory needs. `runSubprocess` / `isToolAvailable` are the real
|
||||
* functions exported from src/arsenal/index.ts; `scopeOk` is an optional in-handler target gate.
|
||||
*/
|
||||
export interface AdapterToolDeps {
|
||||
/** Same signature as `runSubprocess` in src/arsenal/index.ts. */
|
||||
runSubprocess: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { timeout?: number; maxOutput?: number }
|
||||
) => Promise<SubprocessResult>;
|
||||
/** Same signature as `isToolAvailable` in src/arsenal/index.ts. */
|
||||
isToolAvailable: (command: string) => Promise<boolean>;
|
||||
/**
|
||||
* Optional per-target scope predicate. Receives the resolved target host/url the adapter would hit;
|
||||
* return false to refuse the call. When omitted, the in-handler scope check is skipped (the
|
||||
* Arsenal-level egress gate in execute() still applies at the engine boundary).
|
||||
*/
|
||||
scopeOk?: (target: string) => boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ARG TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* How a given adapter's binary consumes its target + which context parameter carries that target.
|
||||
*
|
||||
* `build(target, params)` returns the full argv for the subprocess. `targetParam` names the
|
||||
* ToolContext parameter the target is read from (falling back to `context.target.address`).
|
||||
* `defaultTimeoutMs` is the per-adapter subprocess timeout (scanners get longer budgets).
|
||||
*/
|
||||
interface ArgTemplate {
|
||||
targetParam: string;
|
||||
defaultTimeoutMs: number;
|
||||
build: (target: string, params: Record<string, unknown>) => string[];
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined =>
|
||||
typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
||||
|
||||
/**
|
||||
* Per-binary arg templates for the common command-ready adapters. Keyed by `adapter.binary` (the
|
||||
* process name), with `adapter.id` also accepted as a fallback key so callers can template by either.
|
||||
* Anything not listed here falls back to `DEFAULT_TEMPLATE` (pass the target as a positional arg).
|
||||
*
|
||||
* Templates are intentionally conservative — no intrusive flags are auto-added; risk stays where the
|
||||
* catalog put it and the Arsenal egress gate + optional scopeOk fence the target.
|
||||
*/
|
||||
const ARG_TEMPLATES: Record<string, ArgTemplate> = {
|
||||
nmap: {
|
||||
targetParam: 'target',
|
||||
defaultTimeoutMs: 120_000,
|
||||
// Scan flags are HARDCODED — never taken from an LLM-supplied string. A free-form `flags` param
|
||||
// word-split into argv is arbitrary-nmap-flag injection: `-oN`/`-oX` write attacker-chosen files,
|
||||
// `--script` runs arbitrary NSE, `-iL` reads an arbitrary file — none of which the scope gate
|
||||
// inspects. `ports` is the only tunable, accepted ONLY if it is a pure port spec (digits, commas,
|
||||
// dashes); anything else (spaces, letters, an injected flag) is dropped, not passed through.
|
||||
build: (target, params) => {
|
||||
const args = ['-sV', '-T4'];
|
||||
const ports = str(params.ports);
|
||||
if (ports && /^[0-9,-]+$/.test(ports)) args.push('-p', ports);
|
||||
args.push(target);
|
||||
return args;
|
||||
},
|
||||
},
|
||||
nuclei: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 300_000,
|
||||
build: (target, params) => {
|
||||
const severity = str(params.severity) ?? 'medium,high,critical';
|
||||
const tags = str(params.tags);
|
||||
const args = ['-target', target, '-severity', severity, '-silent', '-jsonl'];
|
||||
if (tags) args.push('-tags', tags);
|
||||
return args;
|
||||
},
|
||||
},
|
||||
ffuf: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 120_000,
|
||||
build: (target, params) => {
|
||||
const wordlist = str(params.wordlist) ?? '/usr/share/wordlists/dirb/common.txt';
|
||||
const mc = str(params.mc) ?? '200,301,302,403';
|
||||
return ['-u', target, '-w', wordlist, '-mc', mc, '-o', '/dev/stdout', '-of', 'json', '-s'];
|
||||
},
|
||||
},
|
||||
sqlmap: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 300_000,
|
||||
// Keep level/risk low unless the mission receipt explicitly permits intrusive testing.
|
||||
build: (target, params) => {
|
||||
const level = str(params.level) ?? '1';
|
||||
const risk = str(params.risk) ?? '1';
|
||||
return ['-u', target, '--batch', `--level=${level}`, `--risk=${risk}`];
|
||||
},
|
||||
},
|
||||
gobuster: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 120_000,
|
||||
build: (target, params) => {
|
||||
const mode = str(params.mode) ?? 'dir';
|
||||
const wordlist = str(params.wordlist) ?? '/usr/share/wordlists/dirb/common.txt';
|
||||
return [mode, '-u', target, '-w', wordlist, '-q'];
|
||||
},
|
||||
},
|
||||
nikto: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 300_000,
|
||||
build: (target) => ['-h', target],
|
||||
},
|
||||
httpx: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 60_000,
|
||||
build: (target) => ['-u', target, '-status-code', '-title', '-tech-detect', '-json', '-silent'],
|
||||
},
|
||||
naabu: {
|
||||
targetParam: 'host',
|
||||
defaultTimeoutMs: 120_000,
|
||||
build: (target, params) => {
|
||||
const topPorts = str(params.top_ports) ?? '100';
|
||||
return ['-host', target, '-top-ports', topPorts, '-silent'];
|
||||
},
|
||||
},
|
||||
katana: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 120_000,
|
||||
build: (target) => ['-u', target, '-jsonl', '-silent'],
|
||||
},
|
||||
subfinder: {
|
||||
targetParam: 'domain',
|
||||
defaultTimeoutMs: 120_000,
|
||||
build: (target) => ['-d', target, '-silent'],
|
||||
},
|
||||
dalfox: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 180_000,
|
||||
build: (target) => ['url', target, '--format', 'json', '--silence'],
|
||||
},
|
||||
dig: {
|
||||
targetParam: 'domain',
|
||||
defaultTimeoutMs: 30_000,
|
||||
build: (target, params) => {
|
||||
const type = str(params.type);
|
||||
const args = [target];
|
||||
if (type) args.push(type);
|
||||
args.push('+short');
|
||||
return args;
|
||||
},
|
||||
},
|
||||
host: {
|
||||
targetParam: 'domain',
|
||||
defaultTimeoutMs: 30_000,
|
||||
build: (target) => [target],
|
||||
},
|
||||
whois: {
|
||||
targetParam: 'domain',
|
||||
defaultTimeoutMs: 30_000,
|
||||
build: (target) => [target],
|
||||
},
|
||||
curl: {
|
||||
targetParam: 'url',
|
||||
defaultTimeoutMs: 30_000,
|
||||
build: (target, params) => {
|
||||
const method = str(params.method) ?? 'GET';
|
||||
const args = ['-s', '-i', '-X', method];
|
||||
const data = str(params.data);
|
||||
if (data) {
|
||||
// A `-d`/`--data` value whose first char is `@` (read a local file) or `<` (read stdin) turns
|
||||
// curl into a local-file-disclosure / exfil primitive — `-d @/etc/passwd` POSTs that file's
|
||||
// contents. `--data-raw` sends the body verbatim and disables @/< interpretation entirely; the
|
||||
// explicit reject is belt-and-braces so a caller can never smuggle a file read past the gates.
|
||||
if (/^[@<]/.test(data)) {
|
||||
throw new Error(`curl: refusing a data value starting with '${data[0]}' (would read a local file/stdin).`);
|
||||
}
|
||||
args.push('--data-raw', data);
|
||||
}
|
||||
args.push(target);
|
||||
return args;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Fallback for any mintable adapter without a bespoke template: pass the target as a positional arg. */
|
||||
const DEFAULT_TEMPLATE: ArgTemplate = {
|
||||
targetParam: 'target',
|
||||
defaultTimeoutMs: 120_000,
|
||||
build: (target) => [target],
|
||||
};
|
||||
|
||||
/** The parameter keys a target may arrive under, mirroring the Arsenal's SCOPE_TARGET_KEYS surface. */
|
||||
const TARGET_PARAM_KEYS = ['url', 'target', 'host', 'hostname', 'domain', 'address', 'endpoint', 'base_url'];
|
||||
|
||||
function resolveTemplate(adapter: ToolAdapter): ArgTemplate {
|
||||
return ARG_TEMPLATES[adapter.binary] ?? ARG_TEMPLATES[adapter.id] ?? DEFAULT_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target string for an adapter call: the template's preferred param first, then the other
|
||||
* common target keys, then `context.target.address`. Returns undefined if none is present.
|
||||
*/
|
||||
function resolveTarget(template: ArgTemplate, context: ToolContext): string | undefined {
|
||||
const params = context.parameters || {};
|
||||
const preferred = str(params[template.targetParam]);
|
||||
if (preferred) return preferred;
|
||||
for (const k of TARGET_PARAM_KEYS) {
|
||||
const v = str(params[k]);
|
||||
if (v) return v;
|
||||
}
|
||||
return str(context.target?.address);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FACTORY
|
||||
// =============================================================================
|
||||
|
||||
/** True only for adapters that may be minted as callable tools (safe_command / receipt_required). */
|
||||
export function isMintable(adapter: ToolAdapter): boolean {
|
||||
return adapter.execution === 'safe_command' || adapter.execution === 'receipt_required';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn one catalogued adapter into a callable `CustomTool`.
|
||||
*
|
||||
* Returns `null` for `execution === 'catalog_only'` (metasploit, hydra) and `execution ===
|
||||
* 'import_only'` (bloodhound) — those are NEVER minted, so they cannot be reached through generic
|
||||
* command execution.
|
||||
*/
|
||||
export function adapterToCustomTool(adapter: ToolAdapter, deps: AdapterToolDeps): CustomTool | null {
|
||||
if (!isMintable(adapter)) return null; // catalog_only / import_only are never callable
|
||||
|
||||
const template = resolveTemplate(adapter);
|
||||
|
||||
const handler = async (context: ToolContext): Promise<ToolResult> => {
|
||||
// 1) Degrade (never throw) when the binary is absent — the model picks another tool.
|
||||
if (!(await deps.isToolAvailable(adapter.binary))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${adapter.name} (${adapter.binary}) is not installed. ${adapter.installHint}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Resolve the target this call would hit.
|
||||
const target = resolveTarget(template, context);
|
||||
if (adapter.networked && !target) {
|
||||
return { success: false, error: `${adapter.name} requires a target (${template.targetParam}).` };
|
||||
}
|
||||
|
||||
// 2b) Refuse an option-looking target: a value starting with '-' gets reparsed by the tool as a
|
||||
// FLAG rather than a target (curl `-K`/`-o`, nikto/whois/host positional, the default
|
||||
// positional template) — argument injection that slips past the scope gate, which only
|
||||
// inspects hosts. Reject it at this single choke point, before any argv is built or spawned.
|
||||
if (target && /^-/.test(target)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${adapter.name}: refusing option-looking target '${target}' (leading '-' is not a valid host/URL).`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3) In-handler scope belt-and-braces: refuse an out-of-scope target BEFORE spawning anything.
|
||||
if (target && deps.scopeOk && !deps.scopeOk(target)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `SCOPE DENIED: target '${target}' is not in the authorized scope — ${adapter.binary} refused before execution.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4) Build argv from the per-adapter template and run the subprocess with a per-adapter timeout.
|
||||
// A template may REFUSE a dangerous param (e.g. curl `-d @file` local-file read) by throwing —
|
||||
// convert that into a clean failure result, never an unhandled rejection.
|
||||
let argv: string[];
|
||||
try {
|
||||
argv = template.build(target ?? '', context.parameters || {});
|
||||
} catch (err) {
|
||||
return { success: false, error: `${adapter.name}: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
const result = await deps.runSubprocess(adapter.binary, argv, { timeout: template.defaultTimeoutMs });
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${adapter.binary} exited ${result.exitCode}: ${result.stderr || result.stdout || 'no output'}`,
|
||||
output: result.stdout || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: result.stdout };
|
||||
};
|
||||
|
||||
return {
|
||||
name: toolNameFor(adapter),
|
||||
description: `${adapter.name}: ${adapter.notes} (Kali+ adapter; ${adapter.execution}, risk=${adapter.risk})`,
|
||||
category: adapter.category,
|
||||
// Carry the catalog risk onto the tool so Arsenal.execute()'s approval gate can see it: an
|
||||
// intrusive/credential/dangerous adapter is inert until approved, and warns on the hottest calls.
|
||||
riskTier: adapter.risk,
|
||||
parameters: [
|
||||
{
|
||||
name: template.targetParam,
|
||||
type: 'string',
|
||||
description: `Target for ${adapter.name} (${adapter.evidenceKinds.join(', ') || 'evidence'}).`,
|
||||
required: adapter.networked,
|
||||
},
|
||||
],
|
||||
handler,
|
||||
};
|
||||
}
|
||||
|
||||
/** The stable tool-name a given adapter mints as (used for de-dup against already-registered tools). */
|
||||
export function toolNameFor(adapter: ToolAdapter): string {
|
||||
return `${adapter.id.replace(/[^a-z0-9]+/gi, '_')}_tool`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a list of adapters to callable `CustomTool`s, skipping:
|
||||
* - non-mintable adapters (catalog_only / import_only → dropped),
|
||||
* - any adapter whose minted tool-name is already present in `alreadyRegistered` (so the bespoke
|
||||
* hand-written EXTERNAL_TOOLS like nmap_scan / nuclei_scan win and we don't double-register).
|
||||
*/
|
||||
export function buildAdapterTools(
|
||||
adapters: ToolAdapter[],
|
||||
deps: AdapterToolDeps,
|
||||
alreadyRegistered: ReadonlySet<string> = new Set()
|
||||
): CustomTool[] {
|
||||
const tools: CustomTool[] = [];
|
||||
for (const adapter of adapters) {
|
||||
const tool = adapterToCustomTool(adapter, deps);
|
||||
if (!tool) continue; // catalog_only / import_only
|
||||
if (alreadyRegistered.has(tool.name)) continue; // don't shadow an existing registration
|
||||
tools.push(tool);
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* T3MP3ST Arsenal — capability approval + spicy-action warning gate.
|
||||
*
|
||||
* The human-veto layer that lets an operator be trusted with live post-ex tools (metasploit, hydra,
|
||||
* sqlmap, …) safely: an intrusive/credential/dangerous tool is INERT until the tool has been approved
|
||||
* — approved once, then free for the session ("approve once, then free") — and the hottest actions
|
||||
* still fire a loud, audited WARNING every time so you always SEE what the agent is about to do.
|
||||
*
|
||||
* Two ways a gated tool becomes approved:
|
||||
* 1. INTERACTIVE — the first time an operator reaches for it, `requestApproval` asks the human;
|
||||
* a yes approves the TOOL for the rest of the session.
|
||||
* 2. PRE-AUTHORIZED — a headless / unattended run (benchmarks, pack hunts) is handed an allowlist
|
||||
* up front (`preApprovedTools`); those run free, everything is audited, anything off-list denies.
|
||||
*
|
||||
* FAIL-SAFE: a gated tool that is neither pre-approved nor interactively approvable (no approver
|
||||
* wired) is DENIED — an unattended run never silently fires an exploit because nobody was there to
|
||||
* say no.
|
||||
*
|
||||
* SCOPE (what "gated" covers): a tool is gated iff it carries a gated `riskTier`. By default that is
|
||||
* the OPT-IN specialist arsenal — the purpose-built post-ex drivers (metasploit=dangerous,
|
||||
* hydra=credential) and the Kali+ adapter tools (stamped from their catalog risk). The pre-existing
|
||||
* BUILT-IN probes (sqli_scan, password_spray, …) ship UNGATED for backward-compatibility — their
|
||||
* safety boundary is the always-on egress SCOPE gate (an out-of-scope target is refused regardless of
|
||||
* approval). An operator can extend this gate to cover the spicy built-ins too by launching with
|
||||
* T3MP3ST_GATE_BUILTINS=1, which stamps them with a riskTier. So the fail-safe above applies to every
|
||||
* tool that is gated; it does not, by itself, make the ungated built-ins inert.
|
||||
*
|
||||
* This module is PURE — no engine/server imports, no I/O. The host injects the approver + warning
|
||||
* sink via the policy, so it is fully unit-testable with fakes. Sits behind Arsenal.execute(),
|
||||
* alongside the egress scope gate.
|
||||
*/
|
||||
|
||||
import type { RiskTier } from '../types/index.js';
|
||||
|
||||
// Re-exported so callers can `import { ..., RiskTier } from './approval.js'` in one place.
|
||||
export type { RiskTier };
|
||||
|
||||
/** Tiers that require the tool to be APPROVED (interactively or via the allowlist) before it can run. */
|
||||
const GATED_TIERS: ReadonlySet<RiskTier> = new Set<RiskTier>(['intrusive', 'credential', 'dangerous']);
|
||||
|
||||
/** The hottest tiers — an approved call still fires a loud, audited, NON-BLOCKING warning each time. */
|
||||
const SPICY_TIERS: ReadonlySet<RiskTier> = new Set<RiskTier>(['credential', 'dangerous']);
|
||||
|
||||
/** True for a tool whose risk requires approval before it can execute. Undefined/safe/active → false.
|
||||
* A type guard so callers narrow `riskTier?` to a defined RiskTier after the check. */
|
||||
export function isGatedRisk(risk?: RiskTier): risk is RiskTier {
|
||||
return risk !== undefined && GATED_TIERS.has(risk);
|
||||
}
|
||||
|
||||
/** True for a tool whose action warrants a loud warning even when approved (exploits, cred attacks). */
|
||||
export function isSpicyRisk(risk?: RiskTier): boolean {
|
||||
return risk !== undefined && SPICY_TIERS.has(risk);
|
||||
}
|
||||
|
||||
/** What the agent is about to do — enough for a human to make a call, or for the audit trail. */
|
||||
export interface ApprovalRequest {
|
||||
/** The tool being invoked (the approval unit — "approve once" approves this name). */
|
||||
tool: string;
|
||||
/** The tool's risk tier (drives gating + spiciness). */
|
||||
risk: RiskTier;
|
||||
/** The operator/agent that wants to run it, when known. */
|
||||
operator?: string;
|
||||
/** The resolved target the action would hit, when known (informational for the warning/audit). */
|
||||
target?: string;
|
||||
/** A one-line human-readable summary of the specific action (tool + key params + target). */
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** How a gated call was resolved — recorded on every audit entry. */
|
||||
export type ApprovalOutcome =
|
||||
| 'allowed-preapproved' // already on the allowlist or approved earlier this session
|
||||
| 'allowed-interactive' // the operator said yes just now (and the tool is now free)
|
||||
| 'denied-declined' // the operator said no
|
||||
| 'denied-no-approver'; // fail-safe: gated, not approved, and no approver was wired
|
||||
|
||||
/** One immutable audit record. The controller keeps the full trail for the engagement report. */
|
||||
export interface ApprovalRecord extends ApprovalRequest {
|
||||
outcome: ApprovalOutcome;
|
||||
/** Whether a spicy warning was surfaced for this call. */
|
||||
spicy: boolean;
|
||||
/** Epoch-ms when the decision was made. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface ApprovalPolicy {
|
||||
/**
|
||||
* Tools pre-authorized up front — the headless/unattended allowlist. A gated tool named here runs
|
||||
* free (still audited, still warns on spicy actions). This is also the store the "approve once"
|
||||
* interactive path writes into.
|
||||
*/
|
||||
preApprovedTools?: string[];
|
||||
/**
|
||||
* Interactive approver, asked ONCE per gated tool the first time it is used. Return true to approve
|
||||
* the tool for the rest of the session. If omitted, a gated tool that isn't pre-approved is DENIED
|
||||
* (fail-safe) — the unattended/headless posture.
|
||||
*/
|
||||
requestApproval?: (req: ApprovalRequest) => Promise<boolean>;
|
||||
/**
|
||||
* Sink for the non-blocking spicy-action warning — surface it live (CLI banner / UI toast / event).
|
||||
* Called on every spicy call that is allowed; the same request is also written to the audit trail.
|
||||
*/
|
||||
onWarning?: (req: ApprovalRequest) => void;
|
||||
/**
|
||||
* Sink for EVERY gated decision (allowed or denied), fired as it is recorded to the audit trail.
|
||||
* The engine wires this to an SSE event so the dashboard sees a live approval/audit feed.
|
||||
*/
|
||||
onDecision?: (record: ApprovalRecord) => void;
|
||||
/** Injected clock for deterministic tests. Defaults to Date.now. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the session's approved-tool set + the full audit trail, and adjudicates each gated call.
|
||||
* One instance per engine/mission; wired onto the Arsenal via setApprovalController().
|
||||
*/
|
||||
export class ApprovalController {
|
||||
private readonly approved: Set<string>;
|
||||
private readonly audit: ApprovalRecord[] = [];
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(private readonly policy: ApprovalPolicy = {}) {
|
||||
this.approved = new Set(policy.preApprovedTools ?? []);
|
||||
this.now = policy.now ?? Date.now;
|
||||
}
|
||||
|
||||
/** Approve a tool for the rest of the session ("approve once, then free"). */
|
||||
approveTool(name: string): void {
|
||||
this.approved.add(name);
|
||||
}
|
||||
|
||||
/** Is this tool currently approved (via the allowlist or an earlier interactive yes)? */
|
||||
isApproved(name: string): boolean {
|
||||
return this.approved.has(name);
|
||||
}
|
||||
|
||||
/** The names currently approved (defensive copy). */
|
||||
approvedTools(): string[] {
|
||||
return [...this.approved];
|
||||
}
|
||||
|
||||
/** The full audit trail (defensive copy) — every gated decision + whether it warned. */
|
||||
getAudit(): ApprovalRecord[] {
|
||||
return this.audit.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate. Called from Arsenal.execute() BEFORE a tool's handler runs.
|
||||
*
|
||||
* - ungated risk (safe/active/undefined) → allowed, nothing recorded.
|
||||
* - gated + already approved → allowed; a spicy call fires the (non-blocking) warning. Audited.
|
||||
* - gated + not approved + an approver is wired → ask once; yes approves the tool + allows (spicy
|
||||
* still warns), no denies. Audited.
|
||||
* - gated + not approved + NO approver → DENIED (fail-safe). Audited.
|
||||
*/
|
||||
async gate(req: ApprovalRequest): Promise<{ allowed: boolean; reason: string }> {
|
||||
if (!isGatedRisk(req.risk)) return { allowed: true, reason: 'ungated' };
|
||||
|
||||
const spicy = isSpicyRisk(req.risk);
|
||||
const warnIfSpicy = (): void => {
|
||||
if (spicy && this.policy.onWarning) this.policy.onWarning(req);
|
||||
};
|
||||
const record = (outcome: ApprovalOutcome): void => {
|
||||
const rec: ApprovalRecord = { ...req, outcome, spicy, at: this.now() };
|
||||
this.audit.push(rec);
|
||||
this.policy.onDecision?.(rec);
|
||||
};
|
||||
|
||||
// Already approved — the allowlist, or an earlier "approve once".
|
||||
if (this.approved.has(req.tool)) {
|
||||
warnIfSpicy();
|
||||
record('allowed-preapproved');
|
||||
return { allowed: true, reason: `approved (${req.risk})` };
|
||||
}
|
||||
|
||||
// Not yet approved — ask the human once, if an approver is wired.
|
||||
if (this.policy.requestApproval) {
|
||||
const ok = await this.policy.requestApproval(req);
|
||||
if (ok) {
|
||||
this.approveTool(req.tool); // approve once, then free
|
||||
warnIfSpicy();
|
||||
record('allowed-interactive');
|
||||
return { allowed: true, reason: `operator approved ${req.tool}` };
|
||||
}
|
||||
record('denied-declined');
|
||||
return { allowed: false, reason: `${req.tool} (${req.risk}) was declined by the operator` };
|
||||
}
|
||||
|
||||
// Fail-safe: gated, not approved, no way to ask.
|
||||
record('denied-no-approver');
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
`${req.tool} is a ${req.risk} tool and is not authorized. Approve it up front ` +
|
||||
`(pre-authorization allowlist) or via an interactive approver before use.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
+184
-2
@@ -11,6 +11,7 @@ import { promisify } from 'util';
|
||||
import * as net from 'net';
|
||||
import * as dns from 'dns';
|
||||
import * as tls from 'tls';
|
||||
import { ApprovalController, isGatedRisk, type ApprovalRequest } from './approval.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
import type {
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
ToolResult,
|
||||
Target,
|
||||
LLMToolDefinition,
|
||||
RiskTier,
|
||||
} from '../types/index.js';
|
||||
|
||||
const dnsResolve = promisify(dns.resolve);
|
||||
@@ -82,8 +84,121 @@ export interface ToolExecution {
|
||||
// ARSENAL
|
||||
// =============================================================================
|
||||
|
||||
// ── Egress scope gate (Phase-0 pack-hunt safety primitive) ──────────────────────────────────
|
||||
// A HARD allowlist enforced inside Arsenal.execute() BEFORE any handler runs, so an LLM-supplied
|
||||
// tool target can never reach an out-of-scope host. When no scope is set (library/test mode)
|
||||
// enforcement is off; the server/mission sets it from the authorized targets so live runs are gated.
|
||||
// This closes the "a keyless pack operator fires nuclei/nmap/sqlmap at an off-target host" hole:
|
||||
// the only prior scope check was at the HTTP boundary, keyed to the mission target — the per-tool
|
||||
// target the model actually supplies was never checked.
|
||||
export interface ArsenalScope {
|
||||
/** exact hosts / registrable domains explicitly authorized (subdomains allowed) */
|
||||
allowedHosts: string[];
|
||||
/** allow 127.0.0.0/8, localhost, ::1 */
|
||||
allowLoopback: boolean;
|
||||
/** allow RFC-1918 / link-local / ULA lab ranges */
|
||||
allowPrivate: boolean;
|
||||
}
|
||||
|
||||
/** The parameter keys a networked tool reads its target from (dns/web/vuln handlers). */
|
||||
const SCOPE_TARGET_KEYS = ['url', 'target', 'host', 'hostname', 'domain', 'address', 'ip', 'endpoint', 'base_url', 'rhosts', 'rhost'];
|
||||
|
||||
/** Normalize a target-ish value to a bare lowercase host (strip scheme/user/port/path). null if not host-like. */
|
||||
export function hostFromTargetValue(v: unknown): string | null {
|
||||
if (typeof v !== 'string' || !v.trim()) return null;
|
||||
let s = v.trim();
|
||||
try { if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = new URL(s).hostname; } catch { /* treat as bare host */ }
|
||||
s = s.replace(/^[^@/]*@/, '').replace(/\/.*$/, '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
|
||||
return s.toLowerCase() || null;
|
||||
}
|
||||
|
||||
function isLoopbackTargetHost(h: string): boolean {
|
||||
return h === 'localhost' || h === '::1' || /^127(?:\.\d{1,3}){3}$/.test(h);
|
||||
}
|
||||
function isPrivateTargetHost(h: string): boolean {
|
||||
return /^10(?:\.\d{1,3}){3}$/.test(h)
|
||||
|| /^192\.168(?:\.\d{1,3}){2}$/.test(h)
|
||||
|| /^172\.(1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}$/.test(h)
|
||||
|| /^169\.254(?:\.\d{1,3}){2}$/.test(h) // link-local
|
||||
|| /^(fc|fd)[0-9a-f]{2}:/i.test(h); // IPv6 ULA
|
||||
}
|
||||
|
||||
/** The narrowest CIDR prefix that keeps a private/loopback host's whole block in scope — a wider
|
||||
* (smaller-number) mask would spill outside it. Used to scope-validate a CIDR TARGET's range. */
|
||||
function privateBlockMinMask(h: string): number {
|
||||
if (/^192\.168\./.test(h) || /^169\.254\./.test(h)) return 16;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return 12;
|
||||
return 8; // 10/8, 127/8, and (conservatively) IPv6 ULA
|
||||
}
|
||||
|
||||
/** null = in scope (or no host to check); otherwise the out-of-scope host/range that must be blocked. */
|
||||
export function scopeViolation(scope: ArsenalScope | null, context: ToolContext): string | null {
|
||||
if (!scope) return null; // enforcement off until configured
|
||||
// FAIL CLOSED on a value that LOOKS like a network target (scheme-relative "//host" or a
|
||||
// "scheme://…") but yields no parseable host — that's the bypass ("//evil.com" /
|
||||
// "file:///etc/passwd" host-normalize to an empty string and would otherwise slip past the gate).
|
||||
// A plain local path ("/code/src") is NOT a network target and has no host, so it's skipped — local
|
||||
// file-analysis tools (semgrep/trivy/slither) keep working. (adversarial-review C1)
|
||||
const bypassLooking = (v: string): boolean => v.startsWith('//') || v.includes('://');
|
||||
const candidates: Array<{ h: string; v: string }> = [];
|
||||
const raws: unknown[] = [context.target?.address, ...SCOPE_TARGET_KEYS.map((k) => (context.parameters || {})[k])];
|
||||
for (const raw of raws) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) continue;
|
||||
const v = raw.trim();
|
||||
const h = hostFromTargetValue(v);
|
||||
if (!h) {
|
||||
if (bypassLooking(v)) return v; // network-target-shaped but unparseable → block
|
||||
continue; // plain path / non-network value → not a scope target
|
||||
}
|
||||
candidates.push({ h, v });
|
||||
}
|
||||
const allow = scope.allowedHosts.map(a => a.toLowerCase());
|
||||
for (const { h, v } of candidates) {
|
||||
// How is the BASE host admitted — and what CIDR prefix keeps the WHOLE range inside that block?
|
||||
// hostFromTargetValue strips the mask, so "10.0.0.0/0" (base 10.0.0.0 private) would otherwise
|
||||
// sweep the entire internet past a scope authorizing only that base. Validate the range, not the
|
||||
// base. (adversarial-review CIDR mask-strip escape)
|
||||
let minMask: number;
|
||||
if (scope.allowLoopback && isLoopbackTargetHost(h)) minMask = 8; // 127/8, ::1
|
||||
else if (scope.allowPrivate && isPrivateTargetHost(h)) minMask = privateBlockMinMask(h);
|
||||
else if (allow.some(a => a === h || h.endsWith('.' + a))) minMask = 32; // exact-allowed host → only /32
|
||||
else return h; // base host out of scope
|
||||
// treat a trailing "/N" as a CIDR mask ONLY on a bare host/ip (no scheme) — not a URL path segment.
|
||||
const cidr = !v.includes('://') && v.match(/^[^/]+\/(\d{1,3})$/);
|
||||
if (cidr && parseInt(cidr[1], 10) < minMask) return v; // CIDR range extends beyond the authorized block
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Best-effort target for an approval request/warning — the first target-like param, else the
|
||||
* context target address. Informational only (the scope gate does the real host math). */
|
||||
function approvalTargetOf(context: ToolContext): string | undefined {
|
||||
const params = context.parameters || {};
|
||||
for (const k of SCOPE_TARGET_KEYS) {
|
||||
const v = params[k];
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
}
|
||||
return context.target?.address;
|
||||
}
|
||||
|
||||
/** A one-line human-readable summary of a tool call — tool + a few scalar params + target — for the
|
||||
* approval warning and the audit trail. Object/array params are omitted to keep it short + safe. */
|
||||
function describeToolAction(toolName: string, context: ToolContext): string {
|
||||
const summary = Object.entries(context.parameters || {})
|
||||
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object')
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => `${k}=${String(v).slice(0, 48)}`)
|
||||
.join(' ');
|
||||
const target = approvalTargetOf(context);
|
||||
return `${toolName}${summary ? ` (${summary})` : ''}${target ? ` → ${target}` : ''}`;
|
||||
}
|
||||
|
||||
export class Arsenal extends EventEmitter<ArsenalEvents> {
|
||||
private tools: Map<string, CustomTool> = new Map();
|
||||
/** Authorized egress scope; null = enforcement off (set by the server/mission for live runs). */
|
||||
private scope: ArsenalScope | null = null;
|
||||
/** Capability approval gate; null = gating off (backward-compat). The engine wires one for live runs. */
|
||||
private approval: ApprovalController | null = null;
|
||||
private executions: ToolExecution[] = [];
|
||||
|
||||
/**
|
||||
@@ -124,6 +239,14 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
|
||||
return this.getAllTools().filter(t => t.category === category);
|
||||
}
|
||||
|
||||
/** Set (or clear with null) the authorized egress scope enforced in execute(). */
|
||||
setScope(scope: ArsenalScope | null): void { this.scope = scope; }
|
||||
getScope(): ArsenalScope | null { return this.scope; }
|
||||
|
||||
/** Set (or clear) the capability-approval gate enforced in execute() for intrusive/dangerous tools. */
|
||||
setApprovalController(approval: ApprovalController | null): void { this.approval = approval; }
|
||||
getApprovalController(): ApprovalController | null { return this.approval; }
|
||||
|
||||
/**
|
||||
* Execute a tool
|
||||
*/
|
||||
@@ -136,6 +259,38 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
|
||||
throw new Error(`Tool "${toolName}" not found`);
|
||||
}
|
||||
|
||||
// Egress scope gate: deny out-of-scope network targets BEFORE the handler runs. A tool call
|
||||
// never reaches a host outside the authorized scope, regardless of what the model supplied.
|
||||
const blockedHost = scopeViolation(this.scope, context);
|
||||
if (blockedHost) {
|
||||
const denied: ToolResult = {
|
||||
success: false,
|
||||
error: `SCOPE DENIED: target '${blockedHost}' is not in the authorized scope — ${toolName} refused before execution. Only authorized / loopback / lab targets are permitted.`,
|
||||
};
|
||||
this.emit('tool:error', { tool, error: new Error(denied.error) });
|
||||
return denied;
|
||||
}
|
||||
|
||||
// Capability approval + spicy-action warning gate: an intrusive/credential/dangerous tool is inert
|
||||
// until it has been approved (interactively "approve once, then free", or via the headless
|
||||
// pre-authorization allowlist); the hottest actions also fire a loud, audited, non-blocking
|
||||
// warning. Safe/active tools pass straight through. No controller wired = gating off (backward-compat).
|
||||
if (this.approval && isGatedRisk(tool.riskTier)) {
|
||||
const request: ApprovalRequest = {
|
||||
tool: toolName,
|
||||
risk: tool.riskTier,
|
||||
operator: context.operator,
|
||||
target: approvalTargetOf(context),
|
||||
action: describeToolAction(toolName, context),
|
||||
};
|
||||
const decision = await this.approval.gate(request);
|
||||
if (!decision.allowed) {
|
||||
const denied: ToolResult = { success: false, error: `APPROVAL REQUIRED: ${decision.reason}` };
|
||||
this.emit('tool:error', { tool, error: new Error(denied.error) });
|
||||
return denied;
|
||||
}
|
||||
}
|
||||
|
||||
const execution: ToolExecution = {
|
||||
id: randomUUID(),
|
||||
toolName,
|
||||
@@ -189,9 +344,13 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
|
||||
/**
|
||||
* Convert registered tools to LLM tool definitions for function calling
|
||||
*/
|
||||
getToolDefinitions(categories?: string[]): LLMToolDefinition[] {
|
||||
getToolDefinitions(categories?: string[], names?: string[]): LLMToolDefinition[] {
|
||||
let tools = this.getAllTools();
|
||||
if (categories?.length) {
|
||||
// A per-operator NAME allowlist (the archetype's role toolkit) is the precise gate and
|
||||
// takes precedence over the coarse category filter; fall back to categories, then to all.
|
||||
if (names?.length) {
|
||||
tools = tools.filter(t => names.includes(t.name));
|
||||
} else if (categories?.length) {
|
||||
tools = tools.filter(t => categories.includes(t.category));
|
||||
}
|
||||
return tools.map(tool => {
|
||||
@@ -272,6 +431,29 @@ export function createToolContext(
|
||||
// BUILT-IN TOOLS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The built-in probes that are genuinely "spicy" — live credential attacks and active injection
|
||||
* testing. They ship UNGATED (no riskTier below) so every prior run and the headline benchmark keep
|
||||
* firing them freely — their safety boundary is the always-on egress scope gate. An operator who wants
|
||||
* the approval fail-safe to blanket these too opts in with T3MP3ST_GATE_BUILTINS=1, which applies these
|
||||
* tiers via stampSpicyBuiltin() at registration. Kept here, next to BUILTIN_TOOLS, as the single source
|
||||
* of truth for which built-ins are spicy.
|
||||
*/
|
||||
export const SPICY_BUILTIN_TIERS: Readonly<Record<string, RiskTier>> = {
|
||||
password_spray: 'credential',
|
||||
hash_crack: 'credential',
|
||||
sqli_scan: 'intrusive',
|
||||
xss_scan: 'intrusive',
|
||||
};
|
||||
|
||||
/** Copy of `tool` stamped with a gated riskTier iff it is a spicy built-in that isn't already tiered;
|
||||
* otherwise the tool unchanged. Never mutates the shared BUILTIN_TOOLS array — only clones when it
|
||||
* stamps — so the default (ungated) registration and this opt-in path can coexist. */
|
||||
export function stampSpicyBuiltin(tool: CustomTool): CustomTool {
|
||||
const tier = SPICY_BUILTIN_TIERS[tool.name];
|
||||
return tier && tool.riskTier === undefined ? { ...tool, riskTier: tier } : tool;
|
||||
}
|
||||
|
||||
export const BUILTIN_TOOLS: CustomTool[] = [
|
||||
// =============================================================================
|
||||
// RECONNAISSANCE TOOLS
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* T3MP3ST Arsenal — purpose-built post-exploitation drivers (metasploit, hydra).
|
||||
*
|
||||
* The catalog withholds these from the GENERIC adapter factory (`execution: catalog_only`) because
|
||||
* the generic "one-shot CLI + a target arg" shape can't drive them: msfconsole is a command REPL, not
|
||||
* `msfconsole <host>`. So they get real, hand-written drivers here instead — structured module/option
|
||||
* invocation with parseable output — and each carries its `riskTier` so Arsenal.execute()'s approval
|
||||
* gate fences it: a `dangerous`/`credential` tool is inert until the operator has approved it, and the
|
||||
* hottest actions fire a loud audited warning.
|
||||
*
|
||||
* SAFETY (these run live exploitation / real auth traffic):
|
||||
* - the target is scope-checked (the injected `scopeOk`) AND rejected if it looks like a flag.
|
||||
* - metasploit values are sanitized against msf-command injection into the `-x` command string
|
||||
* (a `;` or newline in a module/option would inject extra console commands).
|
||||
* - a missing binary DEGRADES (returns an error result) instead of throwing.
|
||||
*
|
||||
* Dependencies are INJECTED (same `AdapterToolDeps` the adapter factory uses) so this module spawns no
|
||||
* real binaries of its own and is unit-testable with fakes.
|
||||
*/
|
||||
|
||||
import type { AdapterToolDeps } from './adapter-tools.js';
|
||||
import type { CustomTool, ToolContext, ToolResult } from '../types/index.js';
|
||||
|
||||
const str = (v: unknown): string | undefined =>
|
||||
typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
||||
|
||||
/** Resolve the target for a post-ex call: an explicit `target`/`rhosts` param, else the context target. */
|
||||
function resolveTarget(context: ToolContext): string | undefined {
|
||||
const p = context.parameters || {};
|
||||
return str(p.target) ?? str(p.rhosts) ?? str(context.target?.address);
|
||||
}
|
||||
|
||||
/** Reject an option-looking value (leading '-') a tool would reparse as its own flag. */
|
||||
function optionLooking(v: string): boolean {
|
||||
return /^-/.test(v);
|
||||
}
|
||||
|
||||
/** A target a tool would misparse or that defeats the scope gate: leading '-' (a flag), leading '/'
|
||||
* or a scheme `x://` (host-normalizes to empty → fail-open), or an `@` (user@host — the scope gate
|
||||
* checks the host AFTER the @, but msf/hydra would receive the raw value, a checked≠emitted mismatch).
|
||||
* Bare hosts / IPs / CIDRs (10.0.0.0/24) pass — CIDR RANGE is validated by the scope gate. */
|
||||
function unsafeTarget(v: string): boolean {
|
||||
return /^[-/]/.test(v) || /:\/\//.test(v) || v.includes('@');
|
||||
}
|
||||
|
||||
/** msf option keys that set the TARGET host — must not be settable via `options` (that would override
|
||||
* the scope-checked `target`/RHOSTS with an unauthorized host). The target comes only from `target`. */
|
||||
const MSF_TARGET_OPT_KEYS: ReadonlySet<string> = new Set(['rhosts', 'rhost', 'vhost']);
|
||||
|
||||
// =============================================================================
|
||||
// METASPLOIT — resource-command driver via `msfconsole -q -x "<commands>"`
|
||||
// =============================================================================
|
||||
|
||||
// Chars that would break out of an msf command in the `-x` string and inject extra console commands
|
||||
// (`;` = command separator, CR/LF = new line, quotes/backtick = string breakout). A value containing
|
||||
// any of these is refused rather than sanitized-and-run — fail closed.
|
||||
const MSF_UNSAFE = /[;\r\n"'`]/;
|
||||
|
||||
/** Build a Metasploit driver tool: `use <module>; set RHOSTS <target>; [set …]; run; exit`. */
|
||||
export function createMetasploitTool(deps: AdapterToolDeps): CustomTool {
|
||||
const handler = async (context: ToolContext): Promise<ToolResult> => {
|
||||
if (!(await deps.isToolAvailable('msfconsole'))) {
|
||||
return { success: false, error: 'metasploit (msfconsole) is not installed. Install the Metasploit Framework.' };
|
||||
}
|
||||
const p = context.parameters || {};
|
||||
const module = str(p.module);
|
||||
if (!module) return { success: false, error: 'metasploit requires a `module` (e.g. exploit/…, auxiliary/…).' };
|
||||
// module must be a clean msf module PATH — no spaces or metachars that could add stray tokens to
|
||||
// the `use` command (defense-in-depth on top of MSF_UNSAFE, which already blocks ; and newlines).
|
||||
if (!/^[A-Za-z0-9/_.-]+$/.test(module)) {
|
||||
return { success: false, error: `metasploit: invalid module '${module}' (module paths are alphanumeric plus / _ . -).` };
|
||||
}
|
||||
|
||||
const target = resolveTarget(context);
|
||||
if (!target) return { success: false, error: 'metasploit requires a `target` (RHOSTS).' };
|
||||
if (unsafeTarget(target)) return { success: false, error: `metasploit: refusing malformed target '${target}' (must be a bare host / IP / CIDR).` };
|
||||
if (deps.scopeOk && !deps.scopeOk(target)) {
|
||||
return { success: false, error: `SCOPE DENIED: RHOSTS '${target}' is not in the authorized scope — metasploit refused before execution.` };
|
||||
}
|
||||
|
||||
const payload = str(p.payload);
|
||||
if (payload && !/^[A-Za-z0-9/_.-]+$/.test(payload)) {
|
||||
return { success: false, error: `metasploit: invalid payload '${payload}' (payload names are alphanumeric plus / _ . -).` };
|
||||
}
|
||||
const options =
|
||||
p.options && typeof p.options === 'object' && !Array.isArray(p.options)
|
||||
? (p.options as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
// Every option key must be a clean msf option name [A-Za-z0-9_]+. This rejects whitespace-padded or
|
||||
// space-bearing keys ("RHOSTS ", "RHOSTS 9.9.9.9", "AutoRunScript multi/handler") that would slip
|
||||
// past the target-override guard below and inject a SECOND `set` directive into the -x string — a
|
||||
// scope escape to an arbitrary host (msf `set RHOSTS` is last-write-wins). Then refuse any option
|
||||
// that re-sets the target host: the target comes only via the scope-checked `target` param.
|
||||
for (const k of Object.keys(options)) {
|
||||
if (!/^[A-Za-z0-9_]+$/.test(k)) {
|
||||
return { success: false, error: `metasploit: invalid option key '${k}' (msf option names must be alphanumeric/underscore).` };
|
||||
}
|
||||
if (MSF_TARGET_OPT_KEYS.has(k.toLowerCase())) {
|
||||
return { success: false, error: `metasploit: set the target via the \`target\` param, not the \`${k}\` option (target override refused).` };
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize every value that lands in the -x command string against msf-command injection.
|
||||
const values: Array<[string, string]> = [['module', module], ['RHOSTS', target]];
|
||||
if (payload) values.push(['PAYLOAD', payload]);
|
||||
for (const [k, v] of Object.entries(options)) values.push([k, String(v)]);
|
||||
for (const [k, v] of values) {
|
||||
if (MSF_UNSAFE.test(k) || MSF_UNSAFE.test(v)) {
|
||||
return { success: false, error: `metasploit: refusing a value with msf-command metacharacters (;, newline, quotes) in '${k}'.` };
|
||||
}
|
||||
}
|
||||
|
||||
const commands = [
|
||||
`use ${module}`,
|
||||
`set RHOSTS ${target}`,
|
||||
...(payload ? [`set PAYLOAD ${payload}`] : []),
|
||||
...Object.entries(options).map(([k, v]) => `set ${k} ${String(v)}`),
|
||||
'run',
|
||||
'exit',
|
||||
].join('; ');
|
||||
|
||||
const result = await deps.runSubprocess('msfconsole', ['-q', '-x', commands], { timeout: 600_000 });
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `msfconsole exited ${result.exitCode}: ${result.stderr || result.stdout || 'no output'}`,
|
||||
output: result.stdout || undefined,
|
||||
};
|
||||
}
|
||||
return { success: true, output: result.stdout };
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'metasploit_module',
|
||||
description:
|
||||
'Run a Metasploit module (exploit/auxiliary/post) against an in-scope target via msfconsole. ' +
|
||||
'Params: module, target (RHOSTS), payload?, options{}. DANGEROUS — approval-gated live exploitation.',
|
||||
category: 'exploitation',
|
||||
riskTier: 'dangerous',
|
||||
parameters: [
|
||||
{ name: 'module', type: 'string', description: 'Module path (e.g. exploit/linux/http/…, auxiliary/scanner/…).', required: true },
|
||||
{ name: 'target', type: 'string', description: 'RHOSTS — the in-scope target host/IP.', required: true },
|
||||
{ name: 'payload', type: 'string', description: 'Optional payload (e.g. linux/x64/meterpreter/reverse_tcp).', required: false },
|
||||
{ name: 'options', type: 'object', description: 'Optional module options as {KEY: value} (e.g. {RPORT: 8080, LHOST: 10.0.0.2}).', required: false },
|
||||
],
|
||||
handler,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HYDRA — online credential brute-force
|
||||
// =============================================================================
|
||||
|
||||
/** Build a THC-Hydra driver tool: `hydra [-l user|-L userlist] [-p pass|-P passlist] -t N -f service://target`. */
|
||||
export function createHydraTool(deps: AdapterToolDeps): CustomTool {
|
||||
const handler = async (context: ToolContext): Promise<ToolResult> => {
|
||||
if (!(await deps.isToolAvailable('hydra'))) {
|
||||
return { success: false, error: 'hydra is not installed. Install THC-Hydra.' };
|
||||
}
|
||||
const p = context.parameters || {};
|
||||
const service = str(p.service);
|
||||
const target = resolveTarget(context);
|
||||
if (!service) return { success: false, error: 'hydra requires a `service` (ssh, ftp, http-get, smb, …).' };
|
||||
if (!target) return { success: false, error: 'hydra requires a `target`.' };
|
||||
if (!/^[a-z0-9][a-z0-9._+-]*$/i.test(service)) {
|
||||
return { success: false, error: `hydra: invalid service '${service}' (expected a hydra module name).` };
|
||||
}
|
||||
|
||||
const user = str(p.user);
|
||||
const userlist = str(p.userlist);
|
||||
const password = str(p.password);
|
||||
const passlist = str(p.passlist);
|
||||
if (unsafeTarget(target)) return { success: false, error: `hydra: refusing malformed target '${target}' (must be a bare host / IP).` };
|
||||
// Refuse option-looking credential values that hydra would reparse as its own flags.
|
||||
for (const [label, val] of [['user', user], ['password', password], ['userlist', userlist], ['passlist', passlist]] as const) {
|
||||
if (val && optionLooking(val)) return { success: false, error: `hydra: refusing option-looking ${label} '${val}'.` };
|
||||
}
|
||||
if (deps.scopeOk && !deps.scopeOk(target)) {
|
||||
return { success: false, error: `SCOPE DENIED: target '${target}' is not in the authorized scope — hydra refused before execution.` };
|
||||
}
|
||||
|
||||
const argv: string[] = [];
|
||||
if (userlist) argv.push('-L', userlist);
|
||||
else if (user) argv.push('-l', user);
|
||||
else return { success: false, error: 'hydra requires a `user` or a `userlist`.' };
|
||||
if (passlist) argv.push('-P', passlist);
|
||||
else if (password) argv.push('-p', password);
|
||||
else return { success: false, error: 'hydra requires a `password` or a `passlist`.' };
|
||||
|
||||
const tasks = Math.min(Math.max(1, Math.floor(Number(p.tasks) || 4)), 16); // cap to avoid lockouts/DoS
|
||||
argv.push('-t', String(tasks), '-f', `${service}://${target}`);
|
||||
|
||||
const result = await deps.runSubprocess('hydra', argv, { timeout: 600_000 });
|
||||
// hydra found-credential lines look like: "[22][ssh] host: X login: Y password: Z"
|
||||
const hits = [...result.stdout.matchAll(/login:\s*(\S+)\s+password:\s*(\S+)/gi)].map((m) => `${m[1]}:${m[2]}`);
|
||||
if (hits.length) {
|
||||
return { success: true, output: `${result.stdout}\n\nCREDENTIALS FOUND: ${hits.join(', ')}` };
|
||||
}
|
||||
if (result.exitCode !== 0 && !result.stdout) {
|
||||
return { success: false, error: `hydra exited ${result.exitCode}: ${result.stderr || 'no output'}` };
|
||||
}
|
||||
return { success: true, output: result.stdout || 'hydra completed; no credentials found.' };
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'hydra_bruteforce',
|
||||
description:
|
||||
'Online credential brute-force with THC-Hydra against an in-scope service. ' +
|
||||
'Params: service, target, user|userlist, password|passlist, tasks?. CREDENTIAL attack — approval-gated real auth traffic.',
|
||||
category: 'credential-access',
|
||||
riskTier: 'credential',
|
||||
parameters: [
|
||||
{ name: 'service', type: 'string', description: 'Hydra service module (ssh, ftp, http-get, http-post-form, smb, …).', required: true },
|
||||
{ name: 'target', type: 'string', description: 'In-scope target host/IP.', required: true },
|
||||
{ name: 'user', type: 'string', description: 'Single username (or use userlist).', required: false },
|
||||
{ name: 'userlist', type: 'string', description: 'Path to a username wordlist.', required: false },
|
||||
{ name: 'password', type: 'string', description: 'Single password (or use passlist).', required: false },
|
||||
{ name: 'passlist', type: 'string', description: 'Path to a password wordlist.', required: false },
|
||||
{ name: 'tasks', type: 'number', description: 'Parallel tasks (1-16, default 4) — keep low to avoid lockouts.', required: false },
|
||||
],
|
||||
handler,
|
||||
};
|
||||
}
|
||||
|
||||
/** All purpose-built post-ex driver tools (metasploit, hydra). Registered by the engine when the full
|
||||
* arsenal is armed; each is approval-gated by its riskTier. Bloodhound (collector→graph pipeline) is
|
||||
* deliberately deferred — it's an AD-analysis tool, not a target-firing CLI, and least relevant to
|
||||
* bug-bounty/CTF work. */
|
||||
export function buildPostExTools(deps: AdapterToolDeps): CustomTool[] {
|
||||
return [createMetasploitTool(deps), createHydraTool(deps)];
|
||||
}
|
||||
+85
-4
@@ -221,7 +221,20 @@ import { OperatorCell, OperatorAgent, ARCHETYPE_PROFILES } from './operators/ind
|
||||
import { MissionControl, TaskQueue } from './mission/index.js';
|
||||
import { TargetEnvironment } from './target/index.js';
|
||||
import { EvidenceVault } from './evidence/index.js';
|
||||
import { Arsenal, BUILTIN_TOOLS, EXTERNAL_TOOLS } from './arsenal/index.js';
|
||||
import {
|
||||
Arsenal,
|
||||
BUILTIN_TOOLS,
|
||||
EXTERNAL_TOOLS,
|
||||
stampSpicyBuiltin,
|
||||
hostFromTargetValue,
|
||||
scopeViolation,
|
||||
runSubprocess,
|
||||
isToolAvailable,
|
||||
} from './arsenal/index.js';
|
||||
import { buildAdapterTools } from './arsenal/adapter-tools.js';
|
||||
import { buildPostExTools } from './arsenal/post-ex.js';
|
||||
import { ApprovalController, type ApprovalRequest } from './arsenal/approval.js';
|
||||
import { TOOL_ADAPTERS } from './arsenal/catalog.js';
|
||||
import { OpsecController, createBalancedOpsecConfig } from './opsec/index.js';
|
||||
import { CommsChannel } from './comms/index.js';
|
||||
import { AnalysisEngine } from './analysis/index.js';
|
||||
@@ -263,6 +276,8 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
public readonly targetEnv: TargetEnvironment;
|
||||
public readonly vault: EvidenceVault;
|
||||
public readonly arsenal: Arsenal;
|
||||
/** Capability approval + spicy-action warning gate for intrusive/dangerous tools. */
|
||||
public readonly approval: ApprovalController;
|
||||
public readonly opsec: OpsecController;
|
||||
public readonly comms: CommsChannel;
|
||||
public readonly analysis: AnalysisEngine;
|
||||
@@ -331,10 +346,57 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
this.opsec = new OpsecController(config.opsec);
|
||||
this.comms = new CommsChannel();
|
||||
|
||||
// Register built-in tools and external CLI wrappers
|
||||
this.arsenal.registerMany(BUILTIN_TOOLS);
|
||||
// Register built-in tools and external CLI wrappers. The built-in intrusive/credential probes
|
||||
// (sqli_scan, password_spray, …) are the pre-existing honest baseline and stay UNGATED by default —
|
||||
// zero regression: the headline benchmark and every prior run keep firing them freely. Opt in with
|
||||
// T3MP3ST_GATE_BUILTINS=1 to stamp the spicy ones with a riskTier so the same approval gate that
|
||||
// fences the specialist arsenal (metasploit/hydra) also fences them.
|
||||
const gateBuiltins = /^(1|true|yes|on)$/i.test(process.env.T3MP3ST_GATE_BUILTINS ?? '');
|
||||
this.arsenal.registerMany(gateBuiltins ? BUILTIN_TOOLS.map(stampSpicyBuiltin) : BUILTIN_TOOLS);
|
||||
this.arsenal.registerMany(EXTERNAL_TOOLS);
|
||||
|
||||
// Capability approval + spicy-action warning gate. An intrusive/credential/dangerous tool is
|
||||
// INERT until it's approved. Two ways in: (1) headless — a pre-authorization allowlist up front
|
||||
// via T3MP3ST_APPROVED_TOOLS (comma list) runs those tools free; (2) interactive — a host wires an
|
||||
// approver so the operator approves a tool once, then it's free. No approver + not pre-approved =
|
||||
// fail-safe DENY (an unattended run never self-fires an exploit). Every gated call is audited; the
|
||||
// spicy ones (exploits / cred attacks) surface a loud warning. Wired onto the arsenal below so the
|
||||
// gate runs inside Arsenal.execute() alongside the egress scope gate.
|
||||
const preApprovedTools = (process.env.T3MP3ST_APPROVED_TOOLS ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
this.approval = new ApprovalController({
|
||||
preApprovedTools,
|
||||
onWarning: (req: ApprovalRequest) => {
|
||||
// Loud, non-blocking warning so a spicy action is always SEEN. A host UI can also read
|
||||
// this.approval.getAudit() or replace the controller for a richer surface.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`⚠️ SPICY ACTION [${req.risk}] ${req.operator ? req.operator + ' → ' : ''}${req.action}`);
|
||||
},
|
||||
// Bridge every gated decision to the dashboard's live approval/audit feed (connectBroadcast
|
||||
// forwards this engine event to the SSE channel as `arsenal.approval`).
|
||||
onDecision: (record) => this.emit('approval:decision', record),
|
||||
});
|
||||
this.arsenal.setApprovalController(this.approval);
|
||||
|
||||
// Phase-1 (OPT-IN): arm the specialist arsenal. Gated behind T3MP3ST_FULL_ARSENAL so the honest
|
||||
// bash-only benchmark baseline (built-ins only) stays uncontaminated — a full-power / pack hunt
|
||||
// sets it. The generic factory NEVER mints catalog_only/import_only adapters; the post-ex drivers
|
||||
// (metasploit/hydra) are hand-written and each carries a riskTier so the approval gate above fences
|
||||
// them. The egress scope gate in Arsenal.execute() still fences every target; the in-handler
|
||||
// scopeOk here is a second belt-and-braces check on the resolved per-adapter target.
|
||||
if (/^(1|true|on)$/i.test(process.env.T3MP3ST_FULL_ARSENAL ?? '')) {
|
||||
const deps = {
|
||||
runSubprocess,
|
||||
isToolAvailable,
|
||||
scopeOk: (target: string) => scopeViolation(this.arsenal.getScope(), { parameters: { target } }) === null,
|
||||
};
|
||||
const existing = new Set(this.arsenal.getAllTools().map((t) => t.name));
|
||||
this.arsenal.registerMany(buildAdapterTools(TOOL_ADAPTERS, deps, existing));
|
||||
this.arsenal.registerMany(buildPostExTools(deps)); // metasploit_module (dangerous) + hydra_bruteforce (credential)
|
||||
}
|
||||
|
||||
// Advanced modules
|
||||
this.exploit = new ExploitEngine();
|
||||
this.scanner = new ScannerOrchestrator();
|
||||
@@ -424,6 +486,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
// Only mark "seeded" if a mission actually exists — otherwise generateTasksForTarget
|
||||
// no-ops and we'd falsely suppress the tick-loop seeding (leaving operators idle).
|
||||
this.targetEnv.on('target:added', (target) => {
|
||||
this.syncArsenalScope();
|
||||
if (this.mission.getActiveMission()) {
|
||||
this.mission.generateTasksForTarget(target.address);
|
||||
this.taskSeeded = true;
|
||||
@@ -431,6 +494,19 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the arsenal's authorized egress scope from the mission's targets. Operators can only
|
||||
* reach the authorized target hosts (+ loopback + lab/private ranges); every other host is refused
|
||||
* at arsenal.execute() before the handler runs. Called whenever a target is added, so a keyless
|
||||
* operator can never point a networked tool at an off-target host.
|
||||
*/
|
||||
private syncArsenalScope(): void {
|
||||
const allowedHosts = this.targetEnv.getAllTargets()
|
||||
.map((t) => hostFromTargetValue(t.address))
|
||||
.filter((h): h is string => !!h);
|
||||
this.arsenal.setScope({ allowedHosts, allowLoopback: true, allowPrivate: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event forwarding for an operator
|
||||
*/
|
||||
@@ -951,6 +1027,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
this.on('credential:harvested', (data) => broadcast('credential', data));
|
||||
this.on('detection:triggered', (data) => broadcast('detection', data));
|
||||
this.on('mission:phase_changed', (data) => broadcast('phase_changed', data));
|
||||
this.on('approval:decision', (data) => broadcast('arsenal.approval', data));
|
||||
this.on('tick', (count) => {
|
||||
// Broadcast status every 5 ticks to avoid flooding
|
||||
if (typeof count === 'number' && count % 5 === 0) {
|
||||
@@ -973,12 +1050,14 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
|
||||
const operator = this.cell.spawnOperator(callsign, archetype);
|
||||
this.setupOperatorEvents(operator);
|
||||
|
||||
// Attach the agent loop scoped to this archetype's tool categories
|
||||
// Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools =
|
||||
// the curated per-operator tool allowlist). toolCategories stays as a coarse fallback.
|
||||
const profile = ARCHETYPE_PROFILES[archetype];
|
||||
const agentLoop = new AgentLoop(this.llm, this.arsenal, {
|
||||
maxIterations: 15,
|
||||
maxTokens: 50000,
|
||||
toolCategories: profile.toolCategories,
|
||||
tools: profile.defaultTools,
|
||||
});
|
||||
operator.attachArsenal(this.arsenal, agentLoop);
|
||||
|
||||
@@ -1067,6 +1146,7 @@ export interface Tempest {
|
||||
targetEnv: TargetEnvironment;
|
||||
vault: EvidenceVault;
|
||||
arsenal: Arsenal;
|
||||
approval: ApprovalController;
|
||||
opsec: OpsecController;
|
||||
comms: CommsChannel;
|
||||
analysis: AnalysisEngine;
|
||||
@@ -1110,6 +1190,7 @@ export function createTempest(config: TempestConfig): Tempest {
|
||||
targetEnv: command.targetEnv,
|
||||
vault: command.vault,
|
||||
arsenal: command.arsenal,
|
||||
approval: command.approval,
|
||||
opsec: command.opsec,
|
||||
comms: command.comms,
|
||||
analysis: command.analysis,
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Shared adjudicate + cite-check (Phase-0 #5).
|
||||
*
|
||||
* The single, PURE home for the two safety primitives the disclosure CLI
|
||||
* (scripts/refute-finding.mjs) and the live pack-audit path
|
||||
* (src/mission/audit-coordinator.ts, per the pack-hunt design)
|
||||
* must SHARE so the two paths can't drift:
|
||||
*
|
||||
* 1. adjudicate(verdicts) — deterministic strict-majority tally.
|
||||
* 2. guardExistsInSource(guard, src) — deterministic cite-check that a cited
|
||||
* killing guard actually appears in source.
|
||||
*
|
||||
* Both are ported verbatim (in behavior) from scripts/refute-finding.mjs. The
|
||||
* ONLY intentional change: guardExistsInSource here takes the SOURCE TEXT as its
|
||||
* second argument (injected by the caller) and returns a plain boolean, so this
|
||||
* module stays pure — no `fs`, no repo-root, no I/O. The refute CLI reads the file
|
||||
* and passes the text in; the live path passes the already-loaded source.
|
||||
*
|
||||
* Why this matters (the pack-hunt design HT-2): a false REFUTE built on a
|
||||
* hallucinated "killing guard" kills a real 0-day AND the dedup guard then
|
||||
* permanently blocks re-finding it. So the cite-check is MANDATORY, non-optional:
|
||||
* a REFUTED vote whose killing_guard is not in source is downgraded to SURVIVED
|
||||
* before the tally (downgradeUnverifiedRefutes()).
|
||||
*
|
||||
* No engine imports — pure functions only.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
/** The two decisive verdicts a single refuter/auditor can cast. */
|
||||
export type RefuterVerdict = 'REFUTED' | 'SURVIVED';
|
||||
|
||||
/** The adjudicated panel verdict. */
|
||||
export type PanelVerdict = 'REFUTED' | 'SURVIVED' | 'INCONCLUSIVE';
|
||||
|
||||
/**
|
||||
* A cited guard the refuter claims dominates the path from untrusted input to the
|
||||
* sink (a bounds check, a normalize pre-pass, a CA binding, ...). `quote` is the
|
||||
* guard's source text, used by guardExistsInSource() as the cite-check anchor.
|
||||
*/
|
||||
export interface KillingGuard {
|
||||
file: string;
|
||||
line: number;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One refuter/auditor vote. A `null` entry (a crashed/timed-out refuter) or a
|
||||
* `verdict` outside {REFUTED,SURVIVED} counts as an abstention — it never adds to
|
||||
* the refute count and never to the panel size.
|
||||
*/
|
||||
export interface RefuterVote {
|
||||
verdict: RefuterVerdict | string;
|
||||
killing_guard?: KillingGuard | null;
|
||||
/** Populated by downgradeUnverifiedRefutes when a REFUTED vote fails the cite-check. */
|
||||
original_verdict?: RefuterVerdict;
|
||||
guard_check?: 'verified' | 'unverified';
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** The deterministic tally result. */
|
||||
export interface Adjudication {
|
||||
verdict: PanelVerdict;
|
||||
/** Number of counted REFUTED votes (post-downgrade, if the caller downgraded). */
|
||||
refutedCount: number;
|
||||
/** Size of the valid (non-abstaining) panel. */
|
||||
total: number;
|
||||
/** The killing guards from the counted REFUTED votes, nulls dropped. */
|
||||
killing_guards: KillingGuard[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ADJUDICATE — deterministic strict-majority tally
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Tally refuter/auditor votes into a single panel verdict.
|
||||
*
|
||||
* Rules (ported from refute-finding.mjs `adjudicate`):
|
||||
* - A vote is VALID only if verdict === 'REFUTED' || verdict === 'SURVIVED'.
|
||||
* `null`, undefined, and any other verdict string (junk, abstention) are
|
||||
* dropped — they count toward NEITHER the refute count NOR the panel size.
|
||||
* - REFUTED requires a STRICT majority of the valid panel: refuted*2 > total.
|
||||
* A tie (exactly half) does NOT refute — SURVIVED wins ties.
|
||||
* - Empty valid panel (n === 0) → INCONCLUSIVE.
|
||||
* - Otherwise → SURVIVED.
|
||||
*
|
||||
* NOTE: this is the tally ONLY. To honor the mandatory cite-check
|
||||
* (the pack-hunt design HT-2) the caller must first pass the votes through
|
||||
* downgradeUnverifiedRefutes() so a REFUTED built on a hallucinated guard has
|
||||
* already been turned into SURVIVED before it's counted here.
|
||||
*/
|
||||
export function adjudicate(verdicts: ReadonlyArray<RefuterVote | null | undefined>): Adjudication {
|
||||
const valid = verdicts.filter(
|
||||
(v): v is RefuterVote => v !== null && v !== undefined && (v.verdict === 'REFUTED' || v.verdict === 'SURVIVED'),
|
||||
);
|
||||
const refuted = valid.filter((v) => v.verdict === 'REFUTED');
|
||||
const n = valid.length;
|
||||
const majorityRefuted = n > 0 && refuted.length * 2 > n; // STRICT majority
|
||||
return {
|
||||
verdict: n === 0 ? 'INCONCLUSIVE' : majorityRefuted ? 'REFUTED' : 'SURVIVED',
|
||||
refutedCount: refuted.length,
|
||||
total: n,
|
||||
killing_guards: refuted
|
||||
.map((v) => v.killing_guard)
|
||||
.filter((g): g is KillingGuard => g !== null && g !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GUARD CITE-CHECK — deterministic, pure
|
||||
// =============================================================================
|
||||
|
||||
const normalize = (s: string): string => String(s).replace(/\s+/g, '').toLowerCase();
|
||||
|
||||
// Strip the wrapping punctuation the operand char-class greedily absorbs — the `(` a comparison
|
||||
// carries from `if (n > cap)`, and the trailing `)` — so a source operand `(n` / `cap)` compares
|
||||
// EQUAL to a cited `n` / `cap`. Also strip a LEADING variable sigil (`$`/`@`/`%`, as in shell/Perl/PHP/
|
||||
// Ruby) so a cited `n > cap` verifies against a source `$n > $cap`. Applied to BOTH cited and source
|
||||
// operands, so any residue cancels out identically on each side.
|
||||
const coreToken = (s: string): string => normalize(s).replace(/^[($@%\s]+/, '').replace(/[)\s;,]+$/, '');
|
||||
|
||||
// A comparison expression: `a OP b`, OP one of >= <= == != > < . The operand class allows the
|
||||
// token chars real code uses around a comparison (identifiers, member access `a.b`, pointer
|
||||
// `a->b`, calls `f()`, array `a[i]`). Kept as a SOURCE STRING so each scan spins up a fresh /g
|
||||
// instance — a shared `/g` RegExp is stateful (lastIndex) and would desync across lines.
|
||||
const COMPARISON_SRC = '([\\w.()\\[\\]>_$@-]+)\\s*(>=|<=|==|!=|>|<)\\s*([\\w.()\\[\\]>_$@-]+)';
|
||||
const COMPARISON_RE = new RegExp(COMPARISON_SRC);
|
||||
|
||||
// The operator seen when the SAME comparison is written with its operands reversed (`a > b` ≡ `b < a`).
|
||||
// Used by the structural cite-check so a cited `a OP b` can verify against a source `b MIRROR[OP] a`.
|
||||
// Equality/inequality are their own mirror; ordering operators flip.
|
||||
const MIRROR_OP: Record<string, string> = { '>': '<', '<': '>', '>=': '<=', '<=': '>=', '==': '==', '!=': '!=' };
|
||||
|
||||
// A quote only counts as a *guard* for the SUBSTRING path (Path 1) if it carries real guard SYNTAX: an
|
||||
// UNAMBIGUOUS comparison operator (>= <= == !=), a control-flow keyword, or a bounds/validate FUNCTION
|
||||
// CALL (keyword immediately followed by `(`). This stops Path 1 from "verifying" a hallucinated guard
|
||||
// that merely quotes one common source token like `length`. NOTE: bare `<` / `>` are DELIBERATELY
|
||||
// excluded here — a single `<`/`>` is indistinguishable from a generic angle bracket at the substring
|
||||
// level (a cited `vector < int` would otherwise match `std::vector<int>`), so `a < b` / `a > b` guards
|
||||
// are verified only by the operand-checked structural path (Path 2), which captures the generic's `>`
|
||||
// into the operand and rejects it.
|
||||
const GUARD_SHAPE_RE =
|
||||
/(?:>=|<=|==|!=)|\b(?:if|else|return|break|continue|throw|goto)\b|\b(?:assert|clamp|min|max|bound|bounded|validate|sanitize|require|abort|reject|checked?|verify|ensure|limit)\s*\(/i;
|
||||
|
||||
/**
|
||||
* Per-language lexical traits: which comment + string forms a file uses, and whether it has `/…/`
|
||||
* REGEX literals. Distinguishing a JS regex `/…/` from a division `a / b` needs full JS grammar (four
|
||||
* hand-rolled scanners each leaked), so codeLinesOf never tries: for a regex-bearing language it just
|
||||
* blanks each line past its first `/` (any regex body dies with it — leak-free by construction); a
|
||||
* no-regex language keeps divisions verbatim (full fidelity). Comment/string forms ARE lexable per
|
||||
* language, so they are stripped precisely. Unknown extensions get the SAFE superset (regex-aware +
|
||||
* every common line-comment marker) so a mis-classified file over-blanks (a false-REJECT/SURVIVED)
|
||||
* rather than leaking.
|
||||
*/
|
||||
interface LangTraits {
|
||||
regexAware: boolean; // `/…/` regex literals → blank each line past its first `/`
|
||||
slashComment: boolean; // `//` line comment (NOT Python, where `//` is floor division)
|
||||
blockComment: boolean; // `/* … */`
|
||||
hashComment: boolean; // `#` line comment (Python/Ruby/shell/YAML/…)
|
||||
dashComment: boolean; // `--` line comment (SQL/Lua/Haskell/Ada)
|
||||
pctComment: boolean; // `%` line comment (MATLAB/Erlang/TeX; also over-blanks Ruby `%q…`/`%r…` literals)
|
||||
splice: boolean; // a `//` line comment continues across a trailing `\` (C/C++ line-splicing only)
|
||||
pyTriple: boolean; // `"""` / `'''` multi-line strings (Python + Java/Kotlin/Scala/Swift text blocks)
|
||||
template: boolean; // JS `` `…` `` template literal with `${…}` interpolation
|
||||
rawBacktick: boolean; // Go `` `…` `` raw string (no escapes/interp); also the safe-superset backtick
|
||||
cppRaw: boolean; // C++ raw string R"delim(…)delim" (+ u8/u/U/L prefixes)
|
||||
rustRaw: boolean; // Rust raw string r#"…"# (+ b prefix)
|
||||
heredoc: 'shell' | 'ruby' | 'php' | false; // `<<WORD` heredocs (php = `<<<WORD` here/nowdoc)
|
||||
podDoc: boolean; // Perl POD (`=word … =cut`) doc blocks + `__END__`/`__DATA__` trailer
|
||||
perlQuote: boolean; // Perl quote-like operators q// qq// qw// qr// qx// m// s/// tr/// y///
|
||||
dollarQuote: boolean; // PostgreSQL dollar-quoted strings $$…$$ / $tag$…$tag$
|
||||
}
|
||||
|
||||
// Ambiguous extensions (.m ObjC/MATLAB, .pl Perl/Prolog, .v Verilog/Coq, .r R/Rebol, .d D/dtrace,
|
||||
// .rb Ruby, .pm, .ex …) and unknown files fall through to the SAFE SUPERSET: every non-destructive
|
||||
// comment marker + all string forms + regex-aware, so whatever the file really is it OVER-blanks
|
||||
// (a false-REJECT/SURVIVED) rather than leaking. `;` and `(* *)` comments are deliberately NOT in the
|
||||
// superset — they collide with ubiquitous C/JS operators (`;`, `(*ptr`) and would gut the cite-check.
|
||||
function langTraits(file: string): LangTraits {
|
||||
const base: LangTraits = { regexAware: false, slashComment: false, blockComment: false, hashComment: false, dashComment: false, pctComment: false, splice: false, pyTriple: false, template: false, rawBacktick: false, cppRaw: false, rustRaw: false, heredoc: false, podDoc: false, perlQuote: false, dollarQuote: false };
|
||||
const set = (o: Partial<LangTraits>): LangTraits => ({ ...base, ...o });
|
||||
const ext = (String(file ?? '').toLowerCase().match(/\.([a-z0-9+#]+)$/) ?? [])[1] ?? '';
|
||||
if (/^(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(ext)) return set({ regexAware: true, slashComment: true, blockComment: true, template: true });
|
||||
if (/^(cpp|cxx|cc|hpp|hh|hxx|c\+\+|ino|cppm|ixx|tpp|ipp)$/.test(ext)) return set({ slashComment: true, blockComment: true, splice: true, cppRaw: true });
|
||||
if (/^(c|h)$/.test(ext)) return set({ slashComment: true, blockComment: true, splice: true });
|
||||
if (/^go$/.test(ext)) return set({ slashComment: true, blockComment: true, rawBacktick: true });
|
||||
if (/^rs$/.test(ext)) return set({ slashComment: true, blockComment: true, rustRaw: true });
|
||||
if (/^(java|kt|kts|scala|swift|cs|dart|groovy|gradle)$/.test(ext)) return set({ slashComment: true, blockComment: true, pyTriple: true });
|
||||
if (/^(py|pyi|pyw|pyx)$/.test(ext)) return set({ hashComment: true, pyTriple: true });
|
||||
if (/^(sh|bash|zsh|ksh)$/.test(ext)) return set({ hashComment: true, heredoc: 'shell' });
|
||||
if (/^(fish|yaml|yml|tf|toml|ini|cfg|conf|dockerfile|mk|cmake|jl|nim|coffee|properties|gitignore)$/.test(ext)) return set({ hashComment: true });
|
||||
if (/^php\d*$/.test(ext)) return set({ slashComment: true, blockComment: true, hashComment: true, heredoc: 'php' });
|
||||
if (/^(sql|hql|psql|pgsql|ddl)$/.test(ext)) return set({ dashComment: true, blockComment: true, dollarQuote: true });
|
||||
if (/^(lua|hs|lhs|elm|adb|ads)$/.test(ext)) return set({ dashComment: true });
|
||||
if (/^(erl|hrl|tex|latex)$/.test(ext)) return set({ pctComment: true });
|
||||
return set({ regexAware: true, slashComment: true, blockComment: true, hashComment: true, dashComment: true, pctComment: true, pyTriple: true, rawBacktick: true, heredoc: 'ruby', podDoc: true, perlQuote: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce source to code-only lines: strip comments and BLANK string / char / template / raw-string /
|
||||
* regex literal contents (per the file's LangTraits), so the cite-check never matches a guard that
|
||||
* lives only in a comment / string / regex — the HT-2 false-VERIFY hazard. Newlines are preserved so
|
||||
* matching stays per-line.
|
||||
*
|
||||
* Handles, per language: line comments (slash-slash, hash, dash-dash, percent) and slash-star block
|
||||
* comments (only slash-slash splices across a trailing backslash, C/C++ only);
|
||||
* ' / " strings (line-bounded, LF+CRLF `\`-continuation); Python/JVM `"""`/`'''`; JS `${…}` templates
|
||||
* (interpolation-stack aware, so a nested template can't leak); Go `` ` `` raw strings; C++ R"d(…)d" and
|
||||
* Rust r#"…"# raw strings; and shell/Ruby `<<WORD` heredocs. A form a language lacks is off (its scanner
|
||||
* never runs); ambiguous/unknown files use the SUPERSET, which biases to over-blank.
|
||||
*/
|
||||
function codeLinesOf(src: string, t: LangTraits): string[] {
|
||||
const n = src.length;
|
||||
let masked = '';
|
||||
const heredocs: Array<{ w: string; indent: boolean; php?: boolean }> = []; // pending terminators, drained at newline
|
||||
const isIdent = (ch: string | undefined): boolean => ch !== undefined && /[A-Za-z0-9_$]/.test(ch);
|
||||
const blank = (len: number): void => { if (len > 0) masked += ' '.repeat(len); };
|
||||
const emitEsc = (i: number): number => {
|
||||
if (src[i + 1] === '\n') { masked += '\n'; return i + 2; } // `\`-LF line continuation
|
||||
if (src[i + 1] === '\r' && src[i + 2] === '\n') { masked += '\n'; return i + 3; } // `\`-CRLF
|
||||
masked += ' '; return i + 2;
|
||||
};
|
||||
const skipLineComment = (i: number, splice: boolean): number => {
|
||||
for (;;) {
|
||||
while (i < n && src[i] !== '\n') i++;
|
||||
if (splice) { let k = i - 1; if (src[k] === '\r') k--; if (k >= 0 && src[k] === '\\') { masked += '\n'; i++; continue; } }
|
||||
return i;
|
||||
}
|
||||
};
|
||||
const blankToClose = (j: number, close: string): number => {
|
||||
while (j < n && src.slice(j, j + close.length) !== close) { masked += src[j] === '\n' ? '\n' : ' '; j++; }
|
||||
const end = Math.min(j + close.length, n);
|
||||
blank(end - j);
|
||||
return end;
|
||||
};
|
||||
const PERL_PAIR: Record<string, string> = { '(': ')', '{': '}', '[': ']', '<': '>' };
|
||||
// Perl quote-like operator (q qq qw qr qx m — 1 body; s tr y — 2 bodies) at position i, or -1.
|
||||
const scanPerlQuote = (i: number): number => {
|
||||
const m = src.slice(i, i + 3).match(/^(qq|qw|qr|qx|tr|q|m|s|y)(?![A-Za-z0-9_])/);
|
||||
if (!m) return -1;
|
||||
const op = m[1];
|
||||
let j = i + op.length;
|
||||
while (src[j] === ' ' || src[j] === '\t') j++;
|
||||
const open = src[j];
|
||||
// a valid delimiter is a single non-alnum, non-space char that isn't an operator/closer that would
|
||||
// make ordinary code (e.g. `s = 1`, `$h->{q}`, `$a // $b`) look like a quote op and over-run.
|
||||
if (!open || /[A-Za-z0-9_\s=,;)\]}>]/.test(open)) return -1;
|
||||
const twoBody = op === 's' || op === 'tr' || op === 'y';
|
||||
blank(j - i + 1); j++; // op + opening delimiter
|
||||
const scanBody = (o: string, cl: string): void => {
|
||||
let depth = 1;
|
||||
while (j < n) {
|
||||
const d = src[j];
|
||||
if (d === '\\') { masked += ' '; masked += src[j + 1] === '\n' ? '\n' : ' '; j += 2; continue; }
|
||||
if (PERL_PAIR[o] && d === o) depth++;
|
||||
else if (d === cl) { depth--; if (depth === 0) { blank(1); j++; return; } }
|
||||
masked += d === '\n' ? '\n' : ' '; j++;
|
||||
}
|
||||
};
|
||||
scanBody(open, PERL_PAIR[open] ?? open);
|
||||
if (twoBody) {
|
||||
let o2 = open, c2 = PERL_PAIR[open] ?? open;
|
||||
if (PERL_PAIR[open]) { while (src[j] === ' ' || src[j] === '\t') j++; o2 = src[j]; c2 = PERL_PAIR[o2] ?? o2; if (o2) { blank(1); j++; } }
|
||||
scanBody(o2, c2); // a char delimiter's close doubles as the 2nd body's open (already consumed)
|
||||
}
|
||||
return j;
|
||||
};
|
||||
const scanTemplate = (i: number): number => {
|
||||
masked += ' '; i++;
|
||||
const stk = ['`']; // '`' = template-string part, '{' = ${…} interpolation, '"'/"'" = string in interp
|
||||
while (i < n && stk.length > 0) {
|
||||
const d = src[i];
|
||||
if (d === '\\') { i = emitEsc(i); continue; }
|
||||
const top = stk[stk.length - 1];
|
||||
if (top === '`') {
|
||||
if (d === '`') { stk.pop(); masked += ' '; i++; continue; }
|
||||
if (d === '$' && src[i + 1] === '{') { stk.push('{'); masked += ' '; i += 2; continue; }
|
||||
} else if (top === '"' || top === "'") {
|
||||
if (d === top) { stk.pop(); masked += ' '; i++; continue; }
|
||||
if (d === '\n') { stk.pop(); masked += '\n'; i++; continue; }
|
||||
} else { // top === '{' : a ${…} interpolation — BLANK it. A comparison there produces template
|
||||
if (d === '}') { stk.pop(); masked += ' '; i++; continue; } // display text, not a bounds guard;
|
||||
if (d === '{') { stk.push('{'); masked += ' '; i++; continue; } // and an interpolation reached
|
||||
if (d === '`') { stk.push('`'); masked += ' '; i++; continue; } // through a nested template must
|
||||
if (d === '"' || d === "'") { stk.push(d); masked += ' '; i++; continue; } // never leak (false-VERIFY).
|
||||
}
|
||||
masked += d === '\n' ? '\n' : ' '; i++;
|
||||
}
|
||||
return i;
|
||||
};
|
||||
for (let i = 0; i < n; ) {
|
||||
const c = src[i];
|
||||
const c2 = src[i + 1];
|
||||
// ---- Perl POD doc blocks (=word … =cut) + __END__/__DATA__ trailer (both line-anchored) ----
|
||||
if (t.podDoc && (i === 0 || src[i - 1] === '\n')) {
|
||||
let le = i; while (le < n && src[le] !== '\n') le++;
|
||||
// __END__/__DATA__ must be at column 0 to be a Perl trailer (an indented one is ordinary code, so
|
||||
// test the line FROM its first char, not a trimmed copy — else an indented match over-blanks to EOF).
|
||||
if (/^__(?:END|DATA)__\b/.test(src.slice(i, le))) { while (i < n) { masked += src[i] === '\n' ? '\n' : ' '; i++; } continue; }
|
||||
// A `=word` at column 0 opens POD (closed by `=cut`) — but ONLY if a `=cut` line actually appears
|
||||
// later; otherwise a stray leading `=` (an expression continuation, config, diff) is real code and
|
||||
// must not be blanked to EOF (that was a false-REJECT that killed real guards).
|
||||
if (c === '=' && /[A-Za-z]/.test(c2) && /\n=cut\b/.test(src.slice(i))) {
|
||||
for (;;) {
|
||||
let j = i; while (j < n && src[j] !== '\n') j++;
|
||||
const isCut = /^=cut\b/.test(src.slice(i, j));
|
||||
blank(j - i); i = j;
|
||||
if (i < n) { masked += '\n'; i++; }
|
||||
if (isCut || i >= n) break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// ---- Perl quote-like operators (q// qq// s/// tr/// m// qr// qw[] …) ----
|
||||
if (t.perlQuote && (c === 'q' || c === 'm' || c === 's' || c === 'y' || c === 't') && !isIdent(src[i - 1])) {
|
||||
const j = scanPerlQuote(i);
|
||||
if (j >= 0) { i = j; continue; }
|
||||
}
|
||||
// ---- line comments ----
|
||||
if (t.slashComment && c === '/' && c2 === '/') { i = skipLineComment(i, t.splice); continue; }
|
||||
if (t.hashComment && c === '#') { i = skipLineComment(i, false); continue; }
|
||||
if (t.dashComment && c === '-' && c2 === '-') { i = skipLineComment(i, false); continue; }
|
||||
if (t.pctComment && c === '%') { i = skipLineComment(i, false); continue; }
|
||||
// ---- block comment ----
|
||||
if (t.blockComment && c === '/' && c2 === '*') {
|
||||
i += 2;
|
||||
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) { if (src[i] === '\n') masked += '\n'; i++; }
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// ---- C++ raw string (u8|u|U|L)?R"delim(…)delim" ----
|
||||
if (t.cppRaw && !isIdent(src[i - 1])) {
|
||||
const m = src.slice(i, i + 4).match(/^(u8R|uR|UR|LR|R)"/);
|
||||
if (m) {
|
||||
let j = i + m[0].length, delim = '';
|
||||
while (j < n && src[j] !== '(' && src[j] !== '\n' && src[j] !== ' ' && src[j] !== '\\' && delim.length <= 16) { delim += src[j]; j++; }
|
||||
if (src[j] === '(') { blank(j - i + 1); i = blankToClose(j + 1, ')' + delim + '"'); continue; }
|
||||
}
|
||||
}
|
||||
// ---- Rust raw string b?r#*"…"#* ----
|
||||
if (t.rustRaw && !isIdent(src[i - 1])) {
|
||||
const m = src.slice(i, i + 260).match(/^b?r(#*)"/); // wide enough for any real `r####…"` hash run
|
||||
if (m) { blank(m[0].length); i = blankToClose(i + m[0].length, '"' + m[1]); continue; }
|
||||
}
|
||||
// ---- PHP heredoc / nowdoc <<<['"]?WORD['"]? (body drained at the newline) ----
|
||||
// PHP opens a here/nowdoc with THREE `<`; its `<<` is a left-shift operator, never a heredoc. The
|
||||
// closer is WORD alone on a line (PHP 7.3+ allows an indented closer, optionally followed by `;`, `,`,
|
||||
// `)`, etc.), so the drain matches WORD at the line's first token rather than the whole line.
|
||||
if (t.heredoc === 'php' && c === '<' && c2 === '<' && src[i + 2] === '<') {
|
||||
let j = i + 3;
|
||||
while (src[j] === ' ' || src[j] === '\t') j++;
|
||||
let q = ''; if (src[j] === "'" || src[j] === '"') { q = src[j]; j++; }
|
||||
let w = ''; while (j < n && /[A-Za-z0-9_]/.test(src[j])) { w += src[j]; j++; }
|
||||
if (q && src[j] === q) j++;
|
||||
if (w) { blank(j - i); heredocs.push({ w, indent: true, php: true }); i = j; continue; }
|
||||
}
|
||||
// ---- heredoc opener <<[-~]?['"]?WORD (body drained at the newline) ----
|
||||
// Guard against `<<<` (shell here-string, not a multi-line heredoc): reject when either neighbour
|
||||
// is another `<`, so neither the 1st nor the 2nd `<` of `<<<` opens a heredoc.
|
||||
if ((t.heredoc === 'shell' || t.heredoc === 'ruby') && c === '<' && c2 === '<' && src[i + 2] !== '<' && src[i + 2] !== '=' && src[i - 1] !== '<') {
|
||||
let j = i + 2;
|
||||
const squig = src[j] === '-' || src[j] === '~'; if (squig) j++;
|
||||
if (t.heredoc === 'shell') while (src[j] === ' ' || src[j] === '\t') j++;
|
||||
let q = ''; if (src[j] === "'" || src[j] === '"' || src[j] === '`') { q = src[j]; j++; }
|
||||
let w = ''; while (j < n && /[A-Za-z0-9_]/.test(src[j])) { w += src[j]; j++; }
|
||||
if (q && src[j] === q) j++;
|
||||
// shell: every <<WORD is a heredoc. ruby/perl: the -/~/quoted forms are unambiguous; a BARE <<WORD
|
||||
// is ambiguous with `<< ` left-shift/append, so treat it as a heredoc ONLY if a matching terminator
|
||||
// line (== WORD) actually appears later — a real heredoc always has one; `arr << CONST` does not.
|
||||
const bareOk = w && !squig && !q && t.heredoc !== 'shell' && new RegExp('\\n[ \\t]*' + w + '[ \\t\\r]*(?:\\n|$)').test(src.slice(j));
|
||||
if (w && (t.heredoc === 'shell' || squig || q || bareOk)) { blank(j - i); heredocs.push({ w, indent: squig || (bareOk as boolean) }); i = j; continue; }
|
||||
}
|
||||
// ---- PostgreSQL dollar-quoted string $$…$$ / $tag$…$tag$ (tag = [A-Za-z0-9_]*) ----
|
||||
if (t.dollarQuote && c === '$') {
|
||||
let k = i + 1; while (k < n && /[A-Za-z0-9_]/.test(src[k])) k++;
|
||||
if (src[k] === '$') { // a real dollar-quote opener (not a `$1` parameter — those have no closing `$`)
|
||||
const tag = src.slice(i, k + 1); // includes both `$`
|
||||
blank(tag.length); i = blankToClose(k + 1, tag); continue;
|
||||
}
|
||||
}
|
||||
// ---- Python / JVM triple-quoted string ----
|
||||
if (t.pyTriple && (c === '"' || c === "'") && src[i + 1] === c && src[i + 2] === c) {
|
||||
blank(3); let j = i + 3;
|
||||
while (j < n && !(src[j] === c && src[j + 1] === c && src[j + 2] === c)) {
|
||||
if (src[j] === '\\') { j = emitEsc(j); continue; }
|
||||
masked += src[j] === '\n' ? '\n' : ' '; j++;
|
||||
}
|
||||
if (j < n) { blank(3); j += 3; }
|
||||
i = j; continue;
|
||||
}
|
||||
// ---- backtick: JS template (${…}-aware) OR Go / superset raw string ----
|
||||
if (t.template && c === '`') { i = scanTemplate(i); continue; }
|
||||
if (t.rawBacktick && c === '`') {
|
||||
masked += ' '; i++;
|
||||
while (i < n && src[i] !== '`') { masked += src[i] === '\n' ? '\n' : ' '; i++; }
|
||||
masked += ' '; if (src[i] === '`') i++;
|
||||
continue;
|
||||
}
|
||||
// ---- ' / " string / char literal (line-bounded; LF/CRLF `\`-continuation) ----
|
||||
if (c === '"' || c === "'") {
|
||||
masked += ' '; i++;
|
||||
while (i < n && src[i] !== c) {
|
||||
if (src[i] === '\\') { i = emitEsc(i); continue; }
|
||||
if (src[i] === '\n') break; // a bare newline ends a ' / " literal
|
||||
masked += ' '; i++;
|
||||
}
|
||||
masked += ' '; if (src[i] === c) i++;
|
||||
continue;
|
||||
}
|
||||
// ---- newline: emit it, then drain any pending heredoc bodies ----
|
||||
if (c === '\n') {
|
||||
masked += '\n'; i++;
|
||||
while (heredocs.length > 0) {
|
||||
const hd = heredocs.shift() as { w: string; indent: boolean; php?: boolean };
|
||||
for (;;) {
|
||||
let j = i; while (j < n && src[j] !== '\n') j++;
|
||||
const stripped = src.slice(i, j).replace(/\r$/, '');
|
||||
blank(j - i); i = j; // blank this body / terminator line
|
||||
// PHP: closer is WORD as the line's first token (indent + trailing `;`/`,`/`)` allowed). Others:
|
||||
// whole-line match (shell/ruby `<<WORD`), trimmed when the opener was indented (`<<-`/`<<~`).
|
||||
const isTerm = hd.php ? new RegExp('^[ \\t]*' + hd.w + '(?![A-Za-z0-9_])').test(stripped)
|
||||
: (hd.indent ? stripped.trim() === hd.w : stripped === hd.w);
|
||||
if (i < n) { masked += '\n'; i++; }
|
||||
if (isTerm || i >= n) break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
masked += c; i++;
|
||||
}
|
||||
const lines = masked.split('\n');
|
||||
if (!t.regexAware) return lines; // no regex literals → every `/` is a division, kept verbatim
|
||||
return lines.map((line) => {
|
||||
const first = line.indexOf('/'); // every regex opens with `/` → blank past it, its body dies too
|
||||
return first === -1 ? line : line.slice(0, first) + ' '.repeat(line.length - first);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cite-check: does the refuter's cited killing `guard` actually appear in `source`?
|
||||
*
|
||||
* Deterministic, and the ONLY thing standing between a hallucinated "if (m_id >= ls.w) continue;"
|
||||
* and a wrongly-refuted real finding (the pack-hunt design HT-2). A guard verifies two ways —
|
||||
* BOTH hardened so a fabricated guard cannot pass on token coincidence alone:
|
||||
*
|
||||
* 1. NORMALIZED SUBSTRING: the whitespace-stripped, lowercased quote is a substring of the
|
||||
* normalized source AND the quote has real guard SHAPE (a comparison operator / control keyword /
|
||||
* bounds-call). A bare common token (`length`, or a copy of the sink line) has no guard shape and
|
||||
* cannot verify. Tolerates reformatting/indentation.
|
||||
* 2. STRUCTURAL COMPARISON: the quote is a comparison `a OP b`, and SOME source line contains a REAL
|
||||
* comparison whose two operands are exactly `a` and `b` (whole tokens, either order). Mere
|
||||
* co-occurrence of the two operand strings on a line is NOT enough — that was the hole that let
|
||||
* `n > src` / `idx >= width` (operands that never actually get compared) mint a bogus REFUTE and
|
||||
* kill a real finding.
|
||||
*
|
||||
* @param guard the cited guard; needs a non-empty `quote`.
|
||||
* @param source the FULL text of the cited source file (injected by the caller — this module does no
|
||||
* I/O). Pass '' if the file is missing → false.
|
||||
* @returns true iff the guard is corroborated by the source; false if the quote is empty/too-short,
|
||||
* lacks guard shape, the source is empty, or the cited operands are not actually compared in
|
||||
* source (hallucinated / paraphrased-absent).
|
||||
*/
|
||||
export function guardExistsInSource(guard: KillingGuard | null | undefined, source: string): boolean {
|
||||
if (!guard || !guard.quote) return false;
|
||||
const src = String(source ?? '');
|
||||
if (!src) return false;
|
||||
const rawQuote = String(guard.quote);
|
||||
|
||||
// Reduce to code-only lines: strip comments AND blank string/char/template-literal (and, for a
|
||||
// regex-bearing language, regex-literal) contents, then match PER LINE — so a hallucinated guard
|
||||
// whose text sits only in a STRING, COMMENT, or REGEX can never verify. The language is decided from
|
||||
// the cited file's extension (default: regex-aware / leak-safe) so a C/C++/… source keeps its
|
||||
// divisions verbatim while a JS/TS/unknown source is over-blanked past any `/`.
|
||||
const codeLines = codeLinesOf(src, langTraits(String(guard.file ?? '')));
|
||||
|
||||
// Path 1 — normalized substring on a SINGLE code line, only for quotes that look like a guard.
|
||||
const q = normalize(rawQuote);
|
||||
if (q.length >= 6 && GUARD_SHAPE_RE.test(rawQuote)) {
|
||||
for (const line of codeLines) if (normalize(line).includes(q)) return true;
|
||||
}
|
||||
|
||||
// Path 2 — the cited comparison's operands must be the operands of a REAL comparison on ONE source
|
||||
// line, not two strings that merely co-occur, AND with a matching OPERATOR. The operator is the whole
|
||||
// semantic content of a bounds guard: a hallucinated `idx >= len` must NOT verify against a source
|
||||
// `idx == len` (a non-bounding equality) just because they share operands. So we require the source
|
||||
// operator to equal the cited one (same operand order) or its mirror (reversed order).
|
||||
const cited = rawQuote.match(COMPARISON_RE);
|
||||
if (cited) {
|
||||
const a = coreToken(cited[1]);
|
||||
const b = coreToken(cited[3]);
|
||||
const op = cited[2];
|
||||
if (a && b && a !== b) {
|
||||
for (const line of codeLines) {
|
||||
const re = new RegExp(COMPARISON_SRC, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const la = coreToken(m[1]);
|
||||
const lb = coreToken(m[3]);
|
||||
const lop = m[2];
|
||||
// A source `<` whose right operand is immediately followed by `,`, `>`, or `<` opens a GENERIC
|
||||
// argument list (`Map<K, V>`, `Vec<u8>`, `Nested<Vec<…>>`), not a comparison — skip it. A
|
||||
// single-arg generic (`Box<T>`) is already rejected because the operand class captures its `>`
|
||||
// (`t>` ≠ `t`); this closes the multi-arg case where a comma cleanly ends the operand.
|
||||
if (lop === '<') {
|
||||
const nextCh = (line.slice(re.lastIndex).match(/^\s*(\S)/) ?? [])[1];
|
||||
if (nextCh === ',' || nextCh === '>' || nextCh === '<') continue;
|
||||
}
|
||||
if (la === a && lb === b && lop === op) return true; // same order, same operator
|
||||
if (la === b && lb === a && lop === MIRROR_OP[op]) return true; // reversed order, mirrored operator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MANDATORY CITE-CHECK ENFORCEMENT
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Apply the mandatory cite-check to a panel BEFORE adjudicate().
|
||||
*
|
||||
* For every REFUTED vote that carries a killing_guard: if guardExistsInSource() is
|
||||
* false, the vote is DOWNGRADED to SURVIVED (its original verdict preserved on
|
||||
* `original_verdict` and `guard_check:'unverified'` recorded for transparency). A
|
||||
* REFUTED vote with NO killing_guard also can't stand on a cite — it too is
|
||||
* downgraded. This enforces HT-2: agreement can never promote a hallucinated
|
||||
* refute; only a cited-and-verified guard refutes.
|
||||
*
|
||||
* Pure: returns a NEW array of NEW vote objects; input is not mutated. `null`
|
||||
* entries and non-REFUTED votes pass through unchanged (copied).
|
||||
*
|
||||
* @param verdicts the raw panel votes.
|
||||
* @param resolveSource maps a guard's `file` to that file's source text (the
|
||||
* caller injects I/O here — e.g. read from disk, or look up an in-memory
|
||||
* code map). Return '' (or undefined) for a missing file → the vote fails
|
||||
* the check and is downgraded.
|
||||
*/
|
||||
export function downgradeUnverifiedRefutes(
|
||||
verdicts: ReadonlyArray<RefuterVote | null | undefined>,
|
||||
resolveSource: (file: string) => string | undefined,
|
||||
): Array<RefuterVote | null> {
|
||||
return verdicts.map((v) => {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (v.verdict !== 'REFUTED') return { ...v };
|
||||
const guard = v.killing_guard ?? null;
|
||||
const source = guard ? resolveSource(guard.file) ?? '' : '';
|
||||
if (guard && guardExistsInSource(guard, source)) {
|
||||
return { ...v, guard_check: 'verified' as const };
|
||||
}
|
||||
// hallucinated / paraphrased-absent / missing-guard → cannot refute.
|
||||
return { ...v, original_verdict: 'REFUTED' as const, verdict: 'SURVIVED' as const, guard_check: 'unverified' as const };
|
||||
});
|
||||
}
|
||||
+10
-10
@@ -73,7 +73,7 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
||||
description: 'Specialized in OSINT, network discovery, and asset enumeration',
|
||||
mitreTactics: ['TA0043'],
|
||||
primaryPhases: [KillChainPhase.RECON],
|
||||
defaultTools: ['nmap_scan', 'port_scan', 'dns_lookup', 'subdomain_enum', 'whois_lookup', 'reverse_dns', 'technology_detect'],
|
||||
defaultTools: ['dns_lookup', 'reverse_dns', 'whois_lookup', 'subdomain_enum', 'nmap_scan', 'port_scan', 'network_trace', 'version_detect', 'robots_txt_fetch', 'cidr_expand', 'technology_detect', 'http_request', 'curl_request', 'header_analysis', 'api_endpoint_discovery'],
|
||||
toolCategories: ['recon', 'web'],
|
||||
capabilities: ['osint', 'dns_enum', 'subdomain_discovery', 'port_scanning', 'service_detection'],
|
||||
techniques: ['T1595', 'T1592', 'T1589', 'T1590', 'T1591'],
|
||||
@@ -84,7 +84,7 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
||||
description: 'Identifies vulnerabilities and security misconfigurations',
|
||||
mitreTactics: ['TA0007'],
|
||||
primaryPhases: [KillChainPhase.WEAPONIZE],
|
||||
defaultTools: ['nuclei_scan', 'ssl_scan', 'cors_check', 'csp_analysis', 'header_analysis', 'http_methods_test', 'api_endpoint_discovery'],
|
||||
defaultTools: ['nuclei_scan', 'ssl_scan', 'cors_check', 'csp_analysis', 'clickjacking_test', 'cookie_analysis', 'http_methods_test', 'open_redirect_test', 'port_scan', 'version_detect', 'technology_detect', 'api_endpoint_discovery', 'header_analysis', 'http_request', 'curl_request'],
|
||||
toolCategories: ['vuln', 'web', 'recon'],
|
||||
capabilities: ['vuln_scanning', 'web_scanning', 'service_enum', 'config_audit'],
|
||||
techniques: ['T1046', 'T1082', 'T1083', 'T1087'],
|
||||
@@ -95,7 +95,7 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
||||
description: 'Executes exploits and achieves initial access',
|
||||
mitreTactics: ['TA0001', 'TA0002'],
|
||||
primaryPhases: [KillChainPhase.DELIVER, KillChainPhase.EXPLOIT],
|
||||
defaultTools: ['sqli_scan', 'xss_scan', 'ssti_test', 'lfi_test', 'open_redirect_test', 'ffuf_fuzz', 'dir_bruteforce', 'http_request'],
|
||||
defaultTools: ['sqli_scan', 'xss_scan', 'ssti_test', 'lfi_test', 'open_redirect_test', 'nuclei_scan', 'ffuf_fuzz', 'dir_bruteforce', 'api_endpoint_discovery', 'http_methods_test', 'password_spray', 'hash_crack', 'base64_decode', 'url_encode', 'jwt_decode', 'http_request', 'curl_request', 'technology_detect', 'header_analysis'],
|
||||
toolCategories: ['vuln', 'web', 'auth', 'util'],
|
||||
capabilities: ['exploit_dev', 'payload_delivery', 'initial_access', 'code_execution'],
|
||||
techniques: ['T1190', 'T1133', 'T1078', 'T1059'],
|
||||
@@ -106,7 +106,7 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
||||
description: 'Moves through networks and escalates privileges',
|
||||
mitreTactics: ['TA0008', 'TA0004'],
|
||||
primaryPhases: [KillChainPhase.INSTALL],
|
||||
defaultTools: ['hash_crack', 'password_spray', 'jwt_decode', 'http_request'],
|
||||
defaultTools: ['hash_crack', 'password_spray', 'jwt_decode', 'cookie_analysis', 'dns_lookup', 'port_scan', 'subdomain_enum', 'network_trace', 'nmap_scan', 'sqli_scan', 'lfi_test', 'cve_lookup', 'base64_decode', 'http_request', 'curl_request', 'technology_detect'],
|
||||
toolCategories: ['recon', 'web', 'auth', 'vuln'],
|
||||
capabilities: ['priv_esc', 'lateral_movement', 'credential_access', 'domain_enum'],
|
||||
techniques: ['T1021', 'T1078', 'T1068', 'T1548'],
|
||||
@@ -117,7 +117,7 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
||||
description: 'Collects and extracts sensitive data',
|
||||
mitreTactics: ['TA0009', 'TA0010'],
|
||||
primaryPhases: [KillChainPhase.ACTIONS],
|
||||
defaultTools: ['http_request', 'curl_request', 'api_endpoint_discovery'],
|
||||
defaultTools: ['http_request', 'curl_request', 'api_endpoint_discovery', 'dir_bruteforce', 'base64_decode', 'url_encode', 'jwt_decode', 'cookie_analysis', 'subdomain_enum', 'robots_txt_fetch', 'technology_detect', 'lfi_test', 'sqli_scan', 'header_analysis', 'cve_lookup'],
|
||||
toolCategories: ['web', 'recon', 'util'],
|
||||
capabilities: ['data_collection', 'exfiltration', 'staging', 'compression'],
|
||||
techniques: ['T1041', 'T1048', 'T1567', 'T1560'],
|
||||
@@ -128,7 +128,7 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
||||
description: 'Establishes persistence and covers tracks',
|
||||
mitreTactics: ['TA0003', 'TA0005'],
|
||||
primaryPhases: [KillChainPhase.INSTALL, KillChainPhase.C2],
|
||||
defaultTools: ['http_request', 'header_analysis', 'cookie_analysis', 'clickjacking_test'],
|
||||
defaultTools: ['http_request', 'curl_request', 'cookie_analysis', 'header_analysis', 'csp_analysis', 'clickjacking_test', 'technology_detect', 'http_methods_test', 'jwt_decode', 'base64_decode', 'url_encode', 'robots_txt_fetch', 'subdomain_enum', 'open_redirect_test', 'ssl_scan'],
|
||||
toolCategories: ['web', 'recon', 'vuln'],
|
||||
capabilities: ['persistence', 'evasion', 'cleanup', 'anti_forensics'],
|
||||
techniques: ['T1547', 'T1053', 'T1136', 'T1070'],
|
||||
@@ -139,8 +139,8 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
||||
description: 'Orchestrates operations and manages agents',
|
||||
mitreTactics: ['TA0011'],
|
||||
primaryPhases: [KillChainPhase.C2],
|
||||
defaultTools: ['cve_lookup', 'technology_detect'],
|
||||
toolCategories: ['recon', 'web'],
|
||||
defaultTools: ['cve_lookup', 'technology_detect', 'http_request', 'curl_request', 'header_analysis', 'nmap_scan', 'port_scan', 'dns_lookup', 'subdomain_enum', 'nuclei_scan', 'sqli_scan', 'api_endpoint_discovery', 'version_detect', 'ssl_scan'],
|
||||
toolCategories: ['recon', 'web', 'vuln'],
|
||||
capabilities: ['orchestration', 'task_management', 'communication', 'decision_making'],
|
||||
techniques: ['T1071', 'T1095', 'T1573', 'T1132'],
|
||||
systemPrompt: OPERATOR_SYSTEM_PROMPTS.coordinator,
|
||||
@@ -150,8 +150,8 @@ export const ARCHETYPE_PROFILES: Record<OperatorArchetype, ArchetypeProfile> = {
|
||||
description: 'Analyzes findings and generates reports',
|
||||
mitreTactics: [],
|
||||
primaryPhases: [KillChainPhase.ACTIONS],
|
||||
defaultTools: ['cve_lookup', 'jwt_decode', 'hash_crack', 'technology_detect'],
|
||||
toolCategories: ['web', 'vuln', 'recon'],
|
||||
defaultTools: ['cve_lookup', 'jwt_decode', 'hash_crack', 'base64_decode', 'url_encode', 'technology_detect', 'ssl_scan', 'header_analysis', 'cors_check', 'csp_analysis', 'cookie_analysis', 'whois_lookup', 'dns_lookup', 'http_request', 'curl_request'],
|
||||
toolCategories: ['web', 'vuln', 'recon', 'util'],
|
||||
capabilities: ['analysis', 'reporting', 'recommendations', 'risk_assessment'],
|
||||
techniques: [],
|
||||
systemPrompt: OPERATOR_SYSTEM_PROMPTS.analyst,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Secret / credential redaction for anything the server serves back to a client — ledgers, the
|
||||
* arsenal-approval audit, SSE events, and persisted state snapshots. Extracted from server.ts so this
|
||||
* load-bearing safety boundary is a PURE, dependency-free module that can be unit-tested in isolation
|
||||
* (importing server.ts would start the HTTP listener + LLM). server.ts imports these back unchanged.
|
||||
*/
|
||||
|
||||
/** Provider secret signatures. Each is applied globally; a match is replaced with `[redacted]`. */
|
||||
export const SECRET_PATTERNS: Record<string, { pattern: RegExp; severity: string; provider: string }> = {
|
||||
aws_access_key: { pattern: /AKIA[0-9A-Z]{16}/g, severity: 'critical', provider: 'AWS' },
|
||||
aws_secret_key: { pattern: /[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, severity: 'critical', provider: 'AWS' },
|
||||
gcp_api_key: { pattern: /AIza[0-9A-Za-z\-_]{35}/g, severity: 'high', provider: 'GCP' },
|
||||
github_token: { pattern: /ghp_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
|
||||
github_oauth: { pattern: /gho_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
|
||||
anthropic_api_key: { pattern: /sk-ant-api\d{2}-[A-Za-z0-9_\-]{16,}/g, severity: 'critical', provider: 'Anthropic' },
|
||||
openai_api_key: { pattern: /sk-[A-Za-z0-9_\-]{20,}/g, severity: 'critical', provider: 'OpenAI' },
|
||||
gitlab_token: { pattern: /glpat-[A-Za-z0-9\-_]{20}/g, severity: 'critical', provider: 'GitLab' },
|
||||
jwt: { pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: 'high', provider: 'JWT' },
|
||||
stripe_live: { pattern: /sk_live_[0-9a-zA-Z]{24}/g, severity: 'critical', provider: 'Stripe' },
|
||||
slack_token: { pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/g, severity: 'high', provider: 'Slack' },
|
||||
postgres_uri: { pattern: /postgres(ql)?:\/\/[^:]+:[^@]+@[^/]+\/\w+/gi, severity: 'critical', provider: 'PostgreSQL' },
|
||||
mongodb_uri: { pattern: /mongodb(\+srv)?:\/\/[^:]+:[^@]+@[^/]+/gi, severity: 'critical', provider: 'MongoDB' },
|
||||
rsa_private: { pattern: new RegExp('-----BEGIN RSA ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'RSA' },
|
||||
openssh_private: { pattern: new RegExp('-----BEGIN OPENSSH ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'OpenSSH' },
|
||||
password_field: { pattern: /password["\s]*[:=]["\s]*[^"'\s]{4,}/gi, severity: 'high', provider: 'Generic' },
|
||||
api_key_field: { pattern: /api[_-]?key["\s]*[:=]["\s]*["']?[A-Za-z0-9\-_]{16,}["']?/gi, severity: 'high', provider: 'Generic' },
|
||||
};
|
||||
|
||||
export function redactString(value: string): string {
|
||||
let redacted = value;
|
||||
for (const { pattern } of Object.values(SECRET_PATTERNS)) {
|
||||
pattern.lastIndex = 0;
|
||||
redacted = redacted.replace(pattern, '[redacted]');
|
||||
}
|
||||
return redacted
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, 'Bearer [redacted]')
|
||||
.replace(/(api[_-]?key|token|secret|password)=([^&\s]+)/gi, '$1=[redacted]')
|
||||
// Generic URL userinfo (basic-auth): `scheme://user:pass@host` — the SECRET_PATTERNS only cover
|
||||
// postgres/mongodb URIs, so an http(s)/ssh/ftp login URL supplied to a gated tool would otherwise
|
||||
// carry its password verbatim into the arsenal-approval audit's target/action (served over both the
|
||||
// REST endpoint and the SSE feed). Scrub the whole userinfo up to the LAST `@` before the host, so a
|
||||
// password that itself contains `@` is fully covered; credential-free URLs are left untouched.
|
||||
.replace(/([a-z][a-z0-9+.-]*:\/\/)(?:[^/\s@]+@)+/gi, '$1[redacted]@');
|
||||
}
|
||||
|
||||
export function redactLedgerText(value: string, limit = 4000): string {
|
||||
const redacted = redactString(value);
|
||||
if (redacted.length <= limit) return redacted;
|
||||
return `${redacted.slice(0, limit)}...[truncated ${redacted.length - limit} chars]`;
|
||||
}
|
||||
|
||||
export function redactSecrets(value: unknown, seen = new WeakSet<object>()): unknown {
|
||||
if (typeof value === 'string') return redactString(value);
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return '[circular]';
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) return value.map((item) => redactSecrets(item, seen));
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (/(api[_-]?key|authorization|cookie|credential|password|secret|token)/i.test(key)) {
|
||||
result[key] = '[redacted]';
|
||||
} else {
|
||||
result[key] = redactSecrets(nested, seen);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+69
-97
@@ -17,6 +17,7 @@ import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { config } from './config/index.js';
|
||||
import { redactString, redactLedgerText, redactSecrets } from './redact.js';
|
||||
import { LLMBackbone } from './llm/index.js';
|
||||
import { TempestCommand } from './index.js';
|
||||
import { OpGeneral } from './general/index.js';
|
||||
@@ -131,28 +132,6 @@ const PAYLOAD_DB = {
|
||||
}
|
||||
};
|
||||
|
||||
const SECRET_PATTERNS: Record<string, { pattern: RegExp; severity: string; provider: string }> = {
|
||||
aws_access_key: { pattern: /AKIA[0-9A-Z]{16}/g, severity: 'critical', provider: 'AWS' },
|
||||
aws_secret_key: { pattern: /[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, severity: 'critical', provider: 'AWS' },
|
||||
gcp_api_key: { pattern: /AIza[0-9A-Za-z\-_]{35}/g, severity: 'high', provider: 'GCP' },
|
||||
github_token: { pattern: /ghp_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
|
||||
github_oauth: { pattern: /gho_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
|
||||
anthropic_api_key: { pattern: /sk-ant-api\d{2}-[A-Za-z0-9_\-]{16,}/g, severity: 'critical', provider: 'Anthropic' },
|
||||
openai_api_key: { pattern: /sk-[A-Za-z0-9_\-]{20,}/g, severity: 'critical', provider: 'OpenAI' },
|
||||
gitlab_token: { pattern: /glpat-[A-Za-z0-9\-_]{20}/g, severity: 'critical', provider: 'GitLab' },
|
||||
jwt: { pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: 'high', provider: 'JWT' },
|
||||
stripe_live: { pattern: /sk_live_[0-9a-zA-Z]{24}/g, severity: 'critical', provider: 'Stripe' },
|
||||
slack_token: { pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/g, severity: 'high', provider: 'Slack' },
|
||||
postgres_uri: { pattern: /postgres(ql)?:\/\/[^:]+:[^@]+@[^/]+\/\w+/gi, severity: 'critical', provider: 'PostgreSQL' },
|
||||
mongodb_uri: { pattern: /mongodb(\+srv)?:\/\/[^:]+:[^@]+@[^/]+/gi, severity: 'critical', provider: 'MongoDB' },
|
||||
rsa_private: { pattern: new RegExp('-----BEGIN RSA ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'RSA' },
|
||||
openssh_private: { pattern: new RegExp('-----BEGIN OPENSSH ' + 'PRIVATE KEY-----', 'g'), severity: 'critical', provider: 'OpenSSH' },
|
||||
password_field: { pattern: /password["\s]*[:=]["\s]*[^"'\s]{4,}/gi, severity: 'high', provider: 'Generic' },
|
||||
api_key_field: { pattern: /api[_-]?key["\s]*[:=]["\s]*["']?[A-Za-z0-9\-_]{16,}["']?/gi, severity: 'high', provider: 'Generic' }
|
||||
};
|
||||
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// SERVER CONFIGURATION
|
||||
// =============================================================================
|
||||
@@ -401,41 +380,8 @@ async function executeCommand(command: string, timeout = 30000): Promise<ToolRes
|
||||
|
||||
|
||||
|
||||
function redactString(value: string): string {
|
||||
let redacted = value;
|
||||
for (const { pattern } of Object.values(SECRET_PATTERNS)) {
|
||||
pattern.lastIndex = 0;
|
||||
redacted = redacted.replace(pattern, '[redacted]');
|
||||
}
|
||||
return redacted
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, 'Bearer [redacted]')
|
||||
.replace(/(api[_-]?key|token|secret|password)=([^&\s]+)/gi, '$1=[redacted]');
|
||||
}
|
||||
|
||||
function redactLedgerText(value: string, limit = 4000): string {
|
||||
const redacted = redactString(value);
|
||||
if (redacted.length <= limit) return redacted;
|
||||
return `${redacted.slice(0, limit)}...[truncated ${redacted.length - limit} chars]`;
|
||||
}
|
||||
|
||||
function redactSecrets(value: unknown, seen = new WeakSet<object>()): unknown {
|
||||
if (typeof value === 'string') return redactString(value);
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return '[circular]';
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) return value.map(item => redactSecrets(item, seen));
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (/(api[_-]?key|authorization|cookie|credential|password|secret|token)/i.test(key)) {
|
||||
result[key] = '[redacted]';
|
||||
} else {
|
||||
result[key] = redactSecrets(nested, seen);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// redactString / redactLedgerText / redactSecrets live in ./redact.ts (pure + unit-tested in isolation;
|
||||
// importing this file would start the HTTP listener). They are imported at the top of this module.
|
||||
|
||||
function rejectDuplicateLedgerId<T>(res: Response, ledger: Map<string, T>, id: unknown, label: string, collectionPath: string): boolean {
|
||||
if (typeof id !== 'string' || !id.trim() || !ledger.has(id.trim())) return false;
|
||||
@@ -452,12 +398,12 @@ function clientLedgerId(value: unknown, prefix: string): string {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DUAL-MODE CONTRACTS - STANDALONE + PLINYOS ORGAN
|
||||
// DUAL-MODE CONTRACTS - STANDALONE + ORGAN MODE
|
||||
// =============================================================================
|
||||
|
||||
type TempestMode = 'standalone' | 'plinyos';
|
||||
type TempestMode = 'standalone' | 'organ';
|
||||
type DraftStatus = 'draft' | 'queued' | 'launched' | 'archived';
|
||||
type DraftSource = 'human' | 'agent' | 'plinyos';
|
||||
type DraftSource = 'human' | 'agent' | 'organ';
|
||||
type MissionFamily = 'web_api' | 'ai_red_team' | 'cloud_infra' | 'smart_contract' | 'code_supply_chain' | 'crypto_secrets' | 'reverse_binary' | 'agent_warfare' | 'social_osint' | 'reporting_remediation';
|
||||
type OperationMode = 'wizard' | 'agent_harness' | 'expert_console' | 'range' | 'review_only';
|
||||
type GuardAction = 'command_execution' | 'network_request' | 'mission_execution' | 'autonomous_execution' | 'model_call';
|
||||
@@ -811,12 +757,12 @@ const ROUTE_SCORECARDS: Record<string, Record<string, number | string>> = {
|
||||
};
|
||||
|
||||
function currentMode(): TempestMode {
|
||||
return process.env.PLINYOS_STATE_DIR || process.env.T3MP3ST_MODE === 'plinyos' ? 'plinyos' : 'standalone';
|
||||
return process.env.ORGAN_STATE_DIR || process.env.T3MP3ST_MODE === 'organ' ? 'organ' : 'standalone';
|
||||
}
|
||||
|
||||
function stateRoot(): string {
|
||||
return process.env.T3MP3ST_STATE_DIR ||
|
||||
(process.env.PLINYOS_STATE_DIR ? `${process.env.PLINYOS_STATE_DIR}/organs/t3mp3st` : 'memory');
|
||||
(process.env.ORGAN_STATE_DIR ? `${process.env.ORGAN_STATE_DIR}/organs/t3mp3st` : 'memory');
|
||||
}
|
||||
|
||||
function stateFilePath(): string | null {
|
||||
@@ -985,7 +931,7 @@ function replaceMapContents<T extends { id: string }>(map: Map<string, T>, value
|
||||
|
||||
function buildStateSnapshot(): Record<string, unknown> {
|
||||
return {
|
||||
schema_version: 'plinyos.t3mp3st_state/v1',
|
||||
schema_version: 't3mp3st.state/v1',
|
||||
savedAt: nowIso(),
|
||||
mode: currentMode(),
|
||||
missionDrafts: [...missionDrafts.values()],
|
||||
@@ -1142,7 +1088,7 @@ function createApprovalRequest(action: GuardAction, target: string, reason: stri
|
||||
reason,
|
||||
status: 'pending',
|
||||
operationId: typeof operationDraft?.operation_id === 'string' ? operationDraft.operation_id : undefined,
|
||||
requestedBy: ['human', 'agent', 'plinyos'].includes(String(body.source)) ? body.source as DraftSource : 'system',
|
||||
requestedBy: ['human', 'agent', 'organ'].includes(String(body.source)) ? body.source as DraftSource : 'system',
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
@@ -1262,7 +1208,7 @@ function buildRoutePreview(draft: MissionDraft): RoutePreview {
|
||||
warnings,
|
||||
},
|
||||
operationDraft: {
|
||||
schema_version: 'plinyos.t3mp3st_operation/v1',
|
||||
schema_version: 't3mp3st.operation/v1',
|
||||
operation_id: `op-${draft.id.replace(/^draft_/, '')}`,
|
||||
mission_id: draft.id,
|
||||
family,
|
||||
@@ -1373,7 +1319,7 @@ function buildMissionBundle(params: { draft?: MissionDraft; operationDraft?: Rec
|
||||
const pressurePaths = buildPressurePaths({ missionId, operationId, family, operationDraft, reproPacks });
|
||||
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_mission_bundle/v1',
|
||||
schema_version: 't3mp3st.mission_bundle/v1',
|
||||
generatedAt: nowIso(),
|
||||
family,
|
||||
missionId: missionId || null,
|
||||
@@ -1539,7 +1485,7 @@ function buildMissionGate(operationDraft: Record<string, unknown>): Record<strin
|
||||
const readinessHold = Boolean(!hypotheses.length || !findings.length || openWorkOrders.length || findingsWithoutRetest.length || findingsWithoutStrongEvidence.length || unresolvedRetests.length);
|
||||
const status = blockCount ? 'blocked' : score >= 80 && freshMissionApprovals.length && !readinessHold ? 'ready' : 'hold';
|
||||
return {
|
||||
schema_version: 'plinyos.t3mp3st_mission_gate/v1',
|
||||
schema_version: 't3mp3st.mission_gate/v1',
|
||||
generatedAt: nowIso(),
|
||||
family,
|
||||
missionId: missionId || null,
|
||||
@@ -1654,7 +1600,7 @@ function latestMissionContext(): Record<string, unknown> {
|
||||
const latest = records[0];
|
||||
if (!latest) {
|
||||
return {
|
||||
schema_version: 'plinyos.t3mp3st_mission_context/v1',
|
||||
schema_version: 't3mp3st.mission_context/v1',
|
||||
missionId: null,
|
||||
operationId: null,
|
||||
family: null,
|
||||
@@ -1684,7 +1630,7 @@ function latestMissionContext(): Record<string, unknown> {
|
||||
const laneSummary = missionLaneSummary({ hypotheses, workOrders, findings, retests });
|
||||
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_mission_context/v1',
|
||||
schema_version: 't3mp3st.mission_context/v1',
|
||||
missionId: missionId || null,
|
||||
operationId: operationId || null,
|
||||
family: latest.family || laneSummary[0]?.family || null,
|
||||
@@ -2340,7 +2286,7 @@ async function buildSelfHealReport(params: Record<string, unknown>): Promise<Rec
|
||||
const health = blocks ? 'blocked' : actionCount ? 'repair' : watchCount ? 'watch' : 'ok';
|
||||
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_self_heal/v1',
|
||||
schema_version: 't3mp3st.self_heal/v1',
|
||||
generatedAt: nowIso(),
|
||||
scope: {
|
||||
missionId: scope.missionId || null,
|
||||
@@ -2466,7 +2412,7 @@ function buildEvidenceGraph(params: Record<string, unknown>): Record<string, unk
|
||||
const laneSummary = missionLaneSummary({ hypotheses, workOrders, findings, retests });
|
||||
const evidenceProvenance = summarizeEvidenceProvenance(evidence);
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_evidence_graph/v1',
|
||||
schema_version: 't3mp3st.evidence_graph/v1',
|
||||
generatedAt: nowIso(),
|
||||
scope: { missionId: missionId || null, operationId: operationId || null, family: family || null },
|
||||
summary: {
|
||||
@@ -2647,7 +2593,7 @@ function buildReproPacks(params: Record<string, unknown>): Record<string, any> {
|
||||
}, {}),
|
||||
};
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_repro_packs/v1',
|
||||
schema_version: 't3mp3st.repro_packs/v1',
|
||||
generatedAt: nowIso(),
|
||||
scope: {
|
||||
missionId: missionId || null,
|
||||
@@ -3031,7 +2977,7 @@ function buildPressurePaths(params: Record<string, unknown>): Record<string, any
|
||||
}, {}),
|
||||
};
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_paths/v1',
|
||||
schema_version: 't3mp3st.pressure_paths/v1',
|
||||
generatedAt: nowIso(),
|
||||
scope: {
|
||||
missionId: missionId || null,
|
||||
@@ -3071,7 +3017,7 @@ function buildPressureCanary(params: Record<string, unknown>): Record<string, an
|
||||
|
||||
if (!path) {
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_canary/v1',
|
||||
schema_version: 't3mp3st.pressure_canary/v1',
|
||||
generatedAt: now,
|
||||
status: 'no_path',
|
||||
canary: null,
|
||||
@@ -3195,7 +3141,7 @@ function buildPressureCanary(params: Record<string, unknown>): Record<string, an
|
||||
});
|
||||
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_canary/v1',
|
||||
schema_version: 't3mp3st.pressure_canary/v1',
|
||||
generatedAt: now,
|
||||
status,
|
||||
path: {
|
||||
@@ -3236,7 +3182,7 @@ function buildPressureDuel(params: Record<string, unknown>): Record<string, any>
|
||||
|
||||
if (!path) {
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_duel/v1',
|
||||
schema_version: 't3mp3st.pressure_duel/v1',
|
||||
generatedAt: now,
|
||||
status: 'no_path',
|
||||
survivabilityScore: 0,
|
||||
@@ -3477,7 +3423,7 @@ function buildPressureDuel(params: Record<string, unknown>): Record<string, any>
|
||||
});
|
||||
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_duel/v1',
|
||||
schema_version: 't3mp3st.pressure_duel/v1',
|
||||
generatedAt: now,
|
||||
status,
|
||||
survivabilityScore,
|
||||
@@ -3519,7 +3465,7 @@ function buildPressureMutations(params: Record<string, unknown>): Record<string,
|
||||
|
||||
if (!path) {
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_mutations/v1',
|
||||
schema_version: 't3mp3st.pressure_mutations/v1',
|
||||
generatedAt: now,
|
||||
status: 'no_path',
|
||||
summary: { total: 0, queued: 0, maxFangScore: 0 },
|
||||
@@ -3739,7 +3685,7 @@ function buildPressureMutations(params: Record<string, unknown>): Record<string,
|
||||
});
|
||||
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_mutations/v1',
|
||||
schema_version: 't3mp3st.pressure_mutations/v1',
|
||||
generatedAt: now,
|
||||
status: survivedDuel ? 'queued' : 'needs_duel',
|
||||
path: {
|
||||
@@ -3783,7 +3729,7 @@ function buildPressureChains(params: Record<string, unknown>): Record<string, an
|
||||
|
||||
if (!path) {
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_chains/v1',
|
||||
schema_version: 't3mp3st.pressure_chains/v1',
|
||||
generatedAt: now,
|
||||
status: 'no_path',
|
||||
summary: { total: 0, queued: 0, maxChainScore: 0, workOrders: 0 },
|
||||
@@ -3804,7 +3750,7 @@ function buildPressureChains(params: Record<string, unknown>): Record<string, an
|
||||
|
||||
if (!mutations.length) {
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_chains/v1',
|
||||
schema_version: 't3mp3st.pressure_chains/v1',
|
||||
generatedAt: now,
|
||||
status: 'no_mutations',
|
||||
path: {
|
||||
@@ -4053,7 +3999,7 @@ function buildPressureChains(params: Record<string, unknown>): Record<string, an
|
||||
});
|
||||
|
||||
return redactSecrets({
|
||||
schema_version: 'plinyos.t3mp3st_pressure_chains/v1',
|
||||
schema_version: 't3mp3st.pressure_chains/v1',
|
||||
generatedAt: now,
|
||||
status: summary.queued ? 'queued' : 'hold',
|
||||
path: {
|
||||
@@ -4478,7 +4424,7 @@ async function buildArsenalStatus(family?: string): Promise<Record<string, unkno
|
||||
const installedCommandReady = commandReadyTools.filter(tool => tool.available).length;
|
||||
const missingCommandReady = commandReadyTools.filter(tool => !tool.available).map(tool => tool.id);
|
||||
return {
|
||||
schema_version: 'plinyos.t3mp3st_arsenal_status/v1',
|
||||
schema_version: 't3mp3st.arsenal_status/v1',
|
||||
family: normalizedFamily || 'all',
|
||||
summary: {
|
||||
...summarizeToolCatalog(catalog),
|
||||
@@ -4554,7 +4500,7 @@ function buildArsenalPlan(params: Record<string, unknown>): Record<string, unkno
|
||||
};
|
||||
});
|
||||
return {
|
||||
schema_version: 'plinyos.t3mp3st_arsenal_plan/v1',
|
||||
schema_version: 't3mp3st.arsenal_plan/v1',
|
||||
family,
|
||||
target,
|
||||
objective: typeof params.objective === 'string' ? params.objective : 'Build a scoped, evidence-first tool plan.',
|
||||
@@ -4603,7 +4549,7 @@ function buildArsenalActivationPlan(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
return {
|
||||
schema_version: 'plinyos.t3mp3st_arsenal_activation/v1',
|
||||
schema_version: 't3mp3st.arsenal_activation/v1',
|
||||
summary: {
|
||||
...summarizeToolCatalog(),
|
||||
frontierMilestone: FRONTIER_ARSENAL_MILESTONE,
|
||||
@@ -4717,7 +4663,7 @@ app.get('/api/arsenal/catalog', (req: Request, res: Response) => {
|
||||
.filter(adapter => !category || adapter.category === category)
|
||||
.filter(adapter => !execution || adapter.execution === execution);
|
||||
res.json({
|
||||
schema_version: 'plinyos.t3mp3st_arsenal_catalog/v1',
|
||||
schema_version: 't3mp3st.arsenal_catalog/v1',
|
||||
family: family || 'all',
|
||||
summary: summarizeToolCatalog(adapters),
|
||||
adapters,
|
||||
@@ -4729,7 +4675,7 @@ app.get('/api/arsenal/catalog', (req: Request, res: Response) => {
|
||||
// defanged, transferable taxonomy the ai_red_team specialist (garak/promptfoo) reasons from.
|
||||
app.get('/api/ai-redteam/playbook', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
schema_version: 'plinyos.t3mp3st_ai_redteam_playbook/v1',
|
||||
schema_version: 't3mp3st.ai_redteam_playbook/v1',
|
||||
source: 'L1B3RT4S + P4RS3LT0NGV3 (public corpus) — defanged methodology, not payloads',
|
||||
doc: 'docs/AI_REDTEAM_TECHNIQUES.md',
|
||||
count: AI_REDTEAM_PLAYBOOK.length,
|
||||
@@ -4752,6 +4698,32 @@ app.get('/api/arsenal/activation', (_req: Request, res: Response) => {
|
||||
res.json(buildArsenalActivationPlan());
|
||||
});
|
||||
|
||||
// Capability-approval gate state (TOOL-level, distinct from the action-level /api/approvals
|
||||
// receipts): the tools approved this session + the full audit trail of gated decisions. Reads the
|
||||
// live mission's ApprovalController when one is running, else the headless pre-authorization
|
||||
// allowlist (T3MP3ST_APPROVED_TOOLS). Live decisions also stream over SSE as `arsenal.approval`.
|
||||
app.get('/api/arsenal/approvals', (_req: Request, res: Response) => {
|
||||
const ctrl = tempestCommand?.approval ?? null;
|
||||
const preAuthorized = (process.env.T3MP3ST_APPROVED_TOOLS ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// redactSecrets: the audit's action strings can carry a credential a tool was invoked with — scrub
|
||||
// it before serving (this REST path bypasses the SSE emitContractEvent redaction otherwise).
|
||||
res.json(redactSecrets({
|
||||
schema_version: 't3mp3st.arsenal_approvals/v1',
|
||||
active: Boolean(ctrl),
|
||||
gatedTiers: ['intrusive', 'credential', 'dangerous'],
|
||||
spicyTiers: ['credential', 'dangerous'],
|
||||
approvedTools: ctrl ? ctrl.approvedTools() : preAuthorized,
|
||||
preAuthorized,
|
||||
audit: ctrl ? ctrl.getAudit() : [],
|
||||
note: ctrl
|
||||
? 'live approval state for the active mission'
|
||||
: 'no active mission — showing the pre-authorization allowlist (T3MP3ST_APPROVED_TOOLS)',
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/api/approvals', (req: Request, res: Response) => {
|
||||
const status = typeof req.query.status === 'string' ? req.query.status : '';
|
||||
const approvals = [...approvalRequests.values()].map(approval => {
|
||||
@@ -4867,7 +4839,7 @@ app.get('/api/operator-runbooks/:family', (req: Request, res: Response) => {
|
||||
app.get('/api/forefront-radar', (req: Request, res: Response) => {
|
||||
const family = typeof req.query.family === 'string' ? req.query.family : '';
|
||||
res.json({
|
||||
schema_version: 'plinyos.t3mp3st_forefront_radar/v1',
|
||||
schema_version: 't3mp3st.forefront_radar/v1',
|
||||
lanes: forefrontPressureForFamily(family),
|
||||
mandate: {
|
||||
purpose: 'show what is possible early in controlled arenas, then convert offensive insight into defensive artifacts',
|
||||
@@ -5191,7 +5163,7 @@ function handleHypothesisDecompose(req: Request, res: Response): void {
|
||||
const existing = [...workOrderLedger.values()].filter(order => order.hypothesisId === hypothesis.id);
|
||||
emitContractEvent('hypothesis.decomposed', { hypothesisId: hypothesis.id, created: orders.length, total: existing.length });
|
||||
res.status(201).json({
|
||||
schema_version: 'plinyos.t3mp3st_work_order_decomposition/v1',
|
||||
schema_version: 't3mp3st.work_order_decomposition/v1',
|
||||
hypothesis,
|
||||
created: orders,
|
||||
workOrders: existing.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
||||
@@ -5304,7 +5276,7 @@ app.get('/api/watch-loop/status', (req: Request, res: Response) => {
|
||||
const cycles = latestWatchCycles(scope.missionId, scope.operationId, scope.family).slice(0, 10);
|
||||
const signals = cycles[0]?.signals || buildWatchSignals(scope);
|
||||
res.json({
|
||||
schema_version: 'plinyos.t3mp3st_watch_loop_status/v1',
|
||||
schema_version: 't3mp3st.watch_loop_status/v1',
|
||||
scope: {
|
||||
missionId: scope.missionId || null,
|
||||
operationId: scope.operationId || null,
|
||||
@@ -5327,7 +5299,7 @@ app.get('/api/watch-loop/status', (req: Request, res: Response) => {
|
||||
app.post('/api/watch-loop/run', (req: Request, res: Response) => {
|
||||
const cycle = runWatchLoop(req.body as Record<string, unknown>);
|
||||
res.status(201).json({
|
||||
schema_version: 'plinyos.t3mp3st_watch_loop_cycle/v1',
|
||||
schema_version: 't3mp3st.watch_loop_cycle/v1',
|
||||
...cycle,
|
||||
});
|
||||
});
|
||||
@@ -5534,7 +5506,7 @@ app.post('/api/mission-drafts', (req: Request, res: Response) => {
|
||||
urgency: ['low', 'normal', 'high', 'critical'].includes(String(body.urgency)) ? body.urgency as MissionDraft['urgency'] : 'normal',
|
||||
opsecPreference: ['overt', 'normal', 'covert', 'ghost'].includes(String(body.opsecPreference)) ? body.opsecPreference as MissionDraft['opsecPreference'] : 'normal',
|
||||
mode: currentMode(),
|
||||
source: ['human', 'agent', 'plinyos'].includes(String(body.source)) ? body.source as DraftSource : 'human',
|
||||
source: ['human', 'agent', 'organ'].includes(String(body.source)) ? body.source as DraftSource : 'human',
|
||||
status: 'draft',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -5597,7 +5569,7 @@ app.post('/api/route-preview', (req: Request, res: Response) => {
|
||||
urgency: ['low', 'normal', 'high', 'critical'].includes(String(body.urgency)) ? body.urgency as MissionDraft['urgency'] : 'normal',
|
||||
opsecPreference: ['overt', 'normal', 'covert', 'ghost'].includes(String(body.opsecPreference)) ? body.opsecPreference as MissionDraft['opsecPreference'] : 'normal',
|
||||
mode: currentMode(),
|
||||
source: ['human', 'agent', 'plinyos'].includes(String(body.source)) ? body.source as DraftSource : 'human',
|
||||
source: ['human', 'agent', 'organ'].includes(String(body.source)) ? body.source as DraftSource : 'human',
|
||||
status: 'draft',
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
@@ -5669,7 +5641,7 @@ app.post('/api/promotion/evaluate', (req: Request, res: Response) => {
|
||||
|
||||
app.get('/api/learning/status', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
schema_version: 'plinyos.t3mp3st_learning_status/v1',
|
||||
schema_version: 't3mp3st.learning_status/v1',
|
||||
policy: {
|
||||
silentLearning: false,
|
||||
proposalRequired: true,
|
||||
@@ -5712,14 +5684,14 @@ app.get('/api/learning/status', (_req: Request, res: Response) => {
|
||||
app.post('/api/learning/run-review', (req: Request, res: Response) => {
|
||||
const review = buildLearningReview(req.body as Record<string, unknown>);
|
||||
res.status(201).json({
|
||||
schema_version: 'plinyos.t3mp3st_learning_review/v1',
|
||||
schema_version: 't3mp3st.learning_review/v1',
|
||||
...review,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/memory/capsule', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
schema_version: 'plinyos.t3mp3st_memory_capsule/v1',
|
||||
schema_version: 't3mp3st.memory_capsule/v1',
|
||||
entries: [...memoryCapsule.values()],
|
||||
policy: 'accepted memory only; proposals are separate and inspectable',
|
||||
});
|
||||
@@ -5728,7 +5700,7 @@ app.get('/api/memory/capsule', (_req: Request, res: Response) => {
|
||||
app.get('/api/memory/proposals', (req: Request, res: Response) => {
|
||||
const status = typeof req.query.status === 'string' ? req.query.status : '';
|
||||
res.json({
|
||||
schema_version: 'plinyos.t3mp3st_memory_proposals/v1',
|
||||
schema_version: 't3mp3st.memory_proposals/v1',
|
||||
proposals: [...memoryProposals.values()]
|
||||
.filter(proposal => !status || proposal.status === status)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
||||
|
||||
@@ -386,6 +386,11 @@ export interface Message {
|
||||
// TOOL TYPES
|
||||
// =============================================================================
|
||||
|
||||
/** A tool's risk tier — the catalog's `risk` vocabulary. Drives the approval + spicy-warning gate
|
||||
* (see src/arsenal/approval.ts): intrusive/credential/dangerous require approval; credential/dangerous
|
||||
* additionally fire a loud warning. Absent/safe/active tools are ungated. */
|
||||
export type RiskTier = 'local_read' | 'passive' | 'active' | 'intrusive' | 'credential' | 'dangerous';
|
||||
|
||||
export interface CustomTool {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -393,6 +398,8 @@ export interface CustomTool {
|
||||
handler: (context: ToolContext) => Promise<ToolResult>;
|
||||
parameters?: ToolParameter[];
|
||||
requiredPermissions?: string[];
|
||||
/** Risk tier carried from the catalog when the tool is a minted adapter — gates approval. */
|
||||
riskTier?: RiskTier;
|
||||
}
|
||||
|
||||
export interface ToolParameter {
|
||||
@@ -541,6 +548,18 @@ export interface CommandEvents {
|
||||
'detection:triggered': DetectionEvent;
|
||||
'mission:phase_changed': { missionId: string; phase: KillChainPhase };
|
||||
'abort:recommended': string;
|
||||
/** A capability-approval gate decision (allowed/denied) on an intrusive/dangerous tool — bridged to
|
||||
* the dashboard's live approval/audit feed. Structural match for arsenal/approval.ts ApprovalRecord. */
|
||||
'approval:decision': {
|
||||
tool: string;
|
||||
risk: RiskTier;
|
||||
operator?: string;
|
||||
target?: string;
|
||||
action: string;
|
||||
outcome: string;
|
||||
spicy: boolean;
|
||||
at: number;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in new issue
Block a user