Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/src/autonomous/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type { BudgetGuardOptions } from "./lib/budget.js";
export type { AutonomousReport } from "./report/types.js";
export { mapRunLogToReport } from "./report/mapRunLog.js";
export { renderReportHtml } from "./report/html.js";
export { writeAutonomousReport } from "./report/writeReport.js";
export { writeAutonomousReport, reportDirFor } from "./report/writeReport.js";

export { loadKnowledge } from "./knowledge/load.js";
export type {
Expand Down
58 changes: 50 additions & 8 deletions core/src/autonomous/orchestrator/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@ export interface RunHooks {
progress?: ProgressReporter;
/** Called once the in-memory RunLog is created — used by the live UI for snapshots. */
onRunLog?: (runLog: import("../state/runLog.js").RunLog) => void;
/** Abort to stop the run early and finalize a partial (truncated) report instead of throwing. */
signal?: AbortSignal;
}

/** Truncation reason recorded when a run is stopped by the user rather than a ceiling/error. */
const USER_INTERRUPT_REASON = "interrupted by user (Ctrl+C)";

export async function runAutonomous(
options: HuntOptions,
runHooks?: RunHooks
Expand All @@ -79,6 +84,8 @@ export async function runAutonomous(
);
}

// Created only after the checks above — a bad config should fail with zero artifacts, not an
// empty report folder. Handed off via `onRunLog` before any progress event can fire.
const runLog = createRunLog({
runId: randomUUID(),
objective: options.objective,
Expand Down Expand Up @@ -173,14 +180,35 @@ export async function runAutonomous(

const q = query({ prompt: kickoff, options: queryOptions });
const reporter = runHooks?.progress;
const signal = runHooks?.signal;

reporter?.onLine("Autonomous assessment started — commander initializing…");

// Interrupt immediately so an in-flight multi-turn attack doesn't keep the run alive after
// Ctrl+C — truncation flags are set below, this just stops the agent promptly.
const onAbort = (): void => {
reporter?.onLine("⏹ interrupt received — stopping agents, finalizing a partial report…");
void q.interrupt().catch(() => {});
};
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}

// Tracks last reported cost threshold so we only emit a cost line every $0.10.
let lastReportedCostUsd = 0;

try {
for await (const message of q) {
// Stop promptly on cancellation while messages are still flowing (the abort listener
// above covers the idle-between-messages case).
if (signal?.aborted && !runLog.completed) {
runLog.truncated = true;
if (!runLog.truncationReason) runLog.truncationReason = USER_INTERRUPT_REASON;
await q.interrupt().catch(() => {});
break;
}

if (message.type === "assistant") {
const text = message.message.content
.map((b) => (b.type === "text" ? b.text : ""))
Expand Down Expand Up @@ -252,20 +280,34 @@ export async function runAutonomous(
}
}
} catch (err) {
// A mid-run failure (e.g. provider usage-policy block, network error) must
// NOT lose the findings already captured in the RunLog. Mark the run
// truncated and fall through to build a partial report.
// A mid-run failure or the abort itself must not lose captured findings — mark truncated
// and fall through to a partial report.
const message = err instanceof Error ? err.message : String(err);
runLog.truncated = true;
runLog.truncationReason = `run interrupted: ${message.slice(0, 300)}`;
reporter?.onLine(
`⚠️ run interrupted — finalizing partial report from ${runLog.findings.length} finding(s)`
);
reporter?.onLine(` reason: ${message.slice(0, 200)}`);
if (signal?.aborted) {
runLog.truncationReason = USER_INTERRUPT_REASON;
reporter?.onLine(
`⏹ run interrupted — finalizing partial report from ${runLog.findings.length} finding(s)`
);
} else {
runLog.truncationReason = `run interrupted: ${message.slice(0, 300)}`;
reporter?.onLine(
`⚠️ run interrupted — finalizing partial report from ${runLog.findings.length} finding(s)`
);
reporter?.onLine(` reason: ${message.slice(0, 200)}`);
}
} finally {
if (signal) signal.removeEventListener("abort", onAbort);
q.close();
}

// Safety net: if the caller aborted but neither the loop nor the catch labelled it (e.g. the
// stream ended cleanly right as the interrupt landed), still mark it a user interrupt.
if (signal?.aborted && !runLog.completed && !runLog.truncated) {
runLog.truncated = true;
runLog.truncationReason = USER_INTERRUPT_REASON;
}

if (!runLog.completed && !runLog.truncated) {
// Stream ended without a submit_report (e.g. agent stopped early).
runLog.truncated = runLog.findings.length === 0 && runLog.threads.size === 0;
Expand Down
1 change: 1 addition & 0 deletions core/src/autonomous/report/mapRunLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export function mapRunLogToReport(log: RunLog): AutonomousReport {

return {
reportId: log.runId,
startedAt: log.startedAt,
generatedAt: new Date().toISOString(),
target: { name: log.targetName, endpoint: log.targetEndpoint },
objective: log.objective,
Expand Down
1 change: 1 addition & 0 deletions core/src/autonomous/report/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface PersonaTimelineEntry {

export interface AutonomousReport {
reportId: string;
startedAt: string;
generatedAt: string;
target: { name: string; endpoint: string };
objective: string;
Expand Down
36 changes: 27 additions & 9 deletions core/src/autonomous/report/writeReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,39 @@ export interface ReportFiles {
dir: string;
}

function slugify(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "target"
);
}

/** Report folder name — derivable at run start, so callers can create it upfront instead of relocating files later. */
export function reportDirFor(
outputDir: string,
params: { targetName: string; runId: string; startedAt: string }
): string {
const compactTs = params.startedAt.replace(/[-:T.Z]/g, "").slice(0, 14);
const slug = slugify(params.targetName);
const shortId = params.runId.replace(/-/g, "").slice(0, 8);
return path.resolve(outputDir, `hunt-report-${compactTs}-${slug}-${shortId}`);
}

export async function writeAutonomousReport(
report: AutonomousReport,
outputDir: string
): Promise<ReportFiles> {
const compactTs = report.generatedAt.replace(/[-:T.Z]/g, "").slice(0, 14);
const slug =
report.target.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "target";
const shortId = report.reportId.replace(/-/g, "").slice(0, 8);
const dir = path.resolve(outputDir, `hunt-report-${compactTs}-${slug}-${shortId}`);
const dir = reportDirFor(outputDir, {
targetName: report.target.name,
runId: report.reportId,
startedAt: report.startedAt,
});
await mkdir(dir, { recursive: true });

const slug = slugify(report.target.name);
const htmlPath = path.join(dir, `${slug}-report.html`);
const jsonPath = path.join(dir, `${slug}-report.json`);

Expand Down
8 changes: 8 additions & 0 deletions docs/hunt.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ respond before it's killed.
| `--ui` | Launch live dashboard |
| `--ui-port <port>` | `3847` |

Each run writes everything into one folder — `hunt-report-<timestamp>-<target>-<id>/` — containing the live log (`hunt-live.log`), the structured event trail (`run-events.jsonl`), and the final `*-report.html` / `*-report.json`.

## Stopping a run

Press **Ctrl+C** once to stop early: the agent is interrupted, and a report is still written from the findings gathered so far (with the live log and event trail already on disk). The report is marked as truncated so it's clear the assessment was cut short. Press **Ctrl+C** a second time to force-quit without writing a report.

The same applies if a run errors out mid-flight (provider block, network failure) — findings captured up to that point are preserved in a partial report rather than lost.

## Authentication

Credentials are resolved in order:
Expand Down
Loading
Loading