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>
118 lines
5.9 KiB
TypeScript
118 lines
5.9 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|