diff --git a/core/src/autonomous/lib/types.ts b/core/src/autonomous/lib/types.ts index c79eee1..0b43389 100644 --- a/core/src/autonomous/lib/types.ts +++ b/core/src/autonomous/lib/types.ts @@ -6,15 +6,26 @@ import type { SessionConfig } from "../../execute/types.js"; /** How the target HTTP agent maintains conversation state. */ export type TargetMode = "stateless" | "stateful"; +/** "http" (default) sends real HTTP requests; "local-script" shells out to `scriptPath` instead. */ +export type TargetKind = "http" | "local-script"; + /** * Transport configuration for the target agent under test. * The agent (Claude SDK) never sees these values — tools hold the client. */ export interface TargetConfig { - /** Display name (defaults to the endpoint host). */ + /** Display name (defaults to the endpoint host, or the script's basename for local-script). */ name: string; - /** Target HTTP endpoint URL. */ + /** "http" (default) or "local-script". */ + type?: TargetKind; + /** + * Target HTTP endpoint URL (`type: "http"`), or a synthetic display string + * (`local-script:`) for `type: "local-script"` — report/UI/prompt + * code treats this as an opaque label, never parses it as a real URL. + */ endpoint: string; + /** Path to a `.js`/`.py` adapter script (`type: "local-script"` only). See docs/sessions.md. */ + scriptPath?: string; /** Bearer API key sent as `Authorization: Bearer ` (optional). */ apiKey?: string; /** Extra static headers merged into every request. */ diff --git a/core/src/autonomous/target/http.ts b/core/src/autonomous/target/http.ts index 65e44a6..965a13a 100644 --- a/core/src/autonomous/target/http.ts +++ b/core/src/autonomous/target/http.ts @@ -10,6 +10,7 @@ import { } from "../../targets/httpClient.js"; import type { TargetConfig } from "../lib/types.js"; import { log } from "../../lib/logger.js"; +import { createLocalScriptTargetClient } from "./localScript.js"; export type { HttpTargetMessage as TargetMessage }; export type { HttpSendResult as TargetSendResult }; @@ -38,6 +39,7 @@ function toHttpConfig(config: TargetConfig): HttpTargetConfig { } export function createTargetClient(config: TargetConfig): TargetClient { + if (config.type === "local-script") return createLocalScriptTargetClient(config); const httpConfig = toHttpConfig(config); const plan = resolveSessionPlan(httpConfig); // Server-owned targets mint their own id, so threadId can't be the wire id. diff --git a/core/src/autonomous/target/localScript.ts b/core/src/autonomous/target/localScript.ts new file mode 100644 index 0000000..3dd737c --- /dev/null +++ b/core/src/autonomous/target/localScript.ts @@ -0,0 +1,37 @@ +// Local-script target client for `opfor hunt` — same TargetClient interface as +// the HTTP client (./http.ts), but shells out to a user script per turn instead +// of making an HTTP request. Shares the stdin/stdout contract documented for +// `opfor run`'s local-script targets (see core/src/lib/localScriptTarget.ts). + +import { invokeLocalTargetScript } from "../../lib/localScriptTarget.js"; +import { isTargetError, RATE_LIMITED_SENTINEL } from "../../targets/agentTarget.js"; +import type { HttpSendResult } from "../../targets/httpClient.js"; +import type { TargetConfig } from "../lib/types.js"; +import type { TargetClient, TargetSendOptions } from "./http.js"; + +export function createLocalScriptTargetClient(config: TargetConfig): TargetClient { + if (!config.scriptPath) { + throw new Error("local-script target is missing `scriptPath`."); + } + const scriptPath = config.scriptPath; + + return { + async send(prompt: string, options: TargetSendOptions): Promise { + // The script owns its own conversation history/session, keyed by sessionId + // (same contract `opfor run` uses). threadId doubles as that session id, so + // hunt's per-thread forking works for free: each forked thread gets its own + // threadId and the script sees it as a distinct session. + const response = await invokeLocalTargetScript(scriptPath, { + prompt, + sessionId: options.threadId, + }); + const isError = isTargetError(response); + return { + response, + isError, + rateLimited: response === RATE_LIMITED_SENTINEL, + errorMessage: isError ? response : undefined, + }; + }, + }; +} diff --git a/core/src/lib/localScriptTarget.ts b/core/src/lib/localScriptTarget.ts index 279bb2d..857c357 100644 --- a/core/src/lib/localScriptTarget.ts +++ b/core/src/lib/localScriptTarget.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; -const TIMEOUT_MS = 30_000; +const TIMEOUT_MS = 240_000; /** Reads the shebang line of a file, returns the interpreter path or null. */ function readShebang(absPath: string): string | null { @@ -99,7 +99,7 @@ export async function invokeLocalTargetScript( } try { const j = JSON.parse(trimmed) as { response?: unknown; error?: unknown }; - if (j.error != null) finish(String(j.error)); + if (j.error != null) finish(`ERROR: ${String(j.error)}`); else if (j.response != null) finish(String(j.response)); else finish(trimmed); } catch { diff --git a/docs/hunt.md b/docs/hunt.md index 7ca46b2..cb8c447 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -26,18 +26,18 @@ Add `--ui` to watch the attack tree unfold in a live dashboard. ### Target -| Option | Description | -| ---------------------------- | --------------------------------------------------------------------------------- | -| `--endpoint ` | Target HTTP endpoint (required) | -| `--objective ` | Attack objective | -| `--objective-file ` | Read objective from file | -| `--target-key-env ` | Env var with target API key | -| `--target-key ` | Target API key directly | -| `--name ` | Display name for target | -| `--target-model ` | Model value in requests | -| `--stateless` / `--stateful` | History handling mode | -| `--session-field ` | Body field for the session id (client-owned, stateful) | -| `--target-config ` | JSON file with a run-style `target` block; enables server-owned & header sessions | +| Option | Description | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `--endpoint ` | Target HTTP endpoint (required unless a local-script target is given via `--target-config`) | +| `--objective ` | Attack objective | +| `--objective-file ` | Read objective from file | +| `--target-key-env ` | Env var with target API key | +| `--target-key ` | Target API key directly | +| `--name ` | Display name for target | +| `--target-model ` | Model value in requests | +| `--stateless` / `--stateful` | History handling mode | +| `--session-field ` | Body field for the session id (client-owned, stateful) | +| `--target-config ` | JSON file with a run-style `target` block; enables server-owned & header sessions, and local-script targets | ### Session handling @@ -74,6 +74,36 @@ The `--ui` setup form also has a Session section. See **[Target session handling the full model. Note: because a server-owned session belongs to the target, forking an attack thread opens a **new** server session. +### Local-script targets + +For targets that can't be modeled as a simple HTTP request/response — async/polling APIs, session ids +embedded in a URL path segment, or auth flows needing custom logic — point `opfor hunt` at a local +script adapter instead of an endpoint, using the same `type: "local-script"` shape and stdin/stdout +contract as `opfor run` (see [Local target scripts](cli.md#local-target-scripts-js--py---agent-mode)). + +```jsonc +// target.json +{ + "target": { + "kind": "agent", + "type": "local-script", + "scriptPath": "./opfor-local-target.js", + }, +} +``` + +```bash +opfor hunt --target-config target.json --objective "Probe for jailbreaks and safety bypasses." +``` + +There is no `--endpoint`, `--script-path`, or similar CLI flag for this — local-script targets are only +configured via `--target-config`. `--name` still works to override the display name (defaults to the +script's basename otherwise). + +Hunt's per-thread `threadId` is passed to the script as `sessionId`, so each forked attack thread gets +an isolated session automatically — no extra wiring needed. The script has 240 seconds per turn to +respond before it's killed. + ### Models | Option | Default | diff --git a/runners/cli/src/commands/hunt.ts b/runners/cli/src/commands/hunt.ts index 287df9c..b24e74a 100644 --- a/runners/cli/src/commands/hunt.ts +++ b/runners/cli/src/commands/hunt.ts @@ -74,7 +74,20 @@ function parseHeaders(raw?: string[]): Record | undefined { // and resolves the API key from `apiKeyEnv` (the file holds the var name). function mapAgentTargetToAutonomous(t: ReturnType): TargetConfig { if (t.type === "local-script") { - throw new Error("local-script targets are not supported by `opfor hunt` (HTTP only)."); + if (!t.scriptPath) throw new Error("local-script target is missing `scriptPath`."); + return { + name: t.name || path.basename(t.scriptPath), + type: "local-script", + scriptPath: t.scriptPath, + // Report/UI/prompt code treats `endpoint` as an opaque display label — never a real URL. + endpoint: `local-script:${t.scriptPath}`, + mode: t.stateful === false ? "stateless" : "stateful", + promptPath: t.promptPath, + responsePath: t.responsePath, + sessionField: t.sessionIdField, + session: t.session, + model: t.model, + }; } if (!t.endpoint) throw new Error("target is missing `endpoint`."); return { @@ -217,8 +230,10 @@ export function registerHuntCommand(program: Command): void { loadDotenv({ path: path.resolve(opts.env), override: true }); } - // If --ui is set and endpoint is missing, launch setup UI - if (opts.ui && !opts.endpoint) { + // If --ui is set and no target was given at all (neither --endpoint nor + // --target-config, e.g. a local-script target), launch the setup wizard. + // Otherwise --ui means the live dashboard for the already-configured target. + if (opts.ui && !opts.endpoint && !opts.targetConfig) { const brainAuth = resolveBrainAuth(); if (!brainAuth) { consola.error(NO_BRAIN_AUTH_MESSAGE); @@ -349,12 +364,25 @@ export function registerHuntCommand(program: Command): void { session: opts.sessionField ? undefined : baseTarget.session, model: opts.targetModel ?? baseTarget.model, }; - if (!target.endpoint) { + if (target.type === "local-script") { + if (!target.scriptPath) { + consola.error( + "No scriptPath: set `scriptPath` on the local-script target in --target-config." + ); + process.exitCode = 1; + return; + } + } else if (!target.endpoint) { consola.error("No endpoint: set --endpoint or an `endpoint` in --target-config."); process.exitCode = 1; return; } - if (!target.name) target.name = new URL(target.endpoint).host; + if (!target.name) { + target.name = + target.type === "local-script" + ? path.basename(target.scriptPath!) + : new URL(target.endpoint).host; + } if (target.mode === "stateful" && !target.sessionField && !target.session) { consola.warn(