Skip to content
Open
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
15 changes: 13 additions & 2 deletions core/src/autonomous/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<scriptPath>`) 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 <key>` (optional). */
apiKey?: string;
/** Extra static headers merged into every request. */
Expand Down
2 changes: 2 additions & 0 deletions core/src/autonomous/target/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions core/src/autonomous/target/localScript.ts
Original file line number Diff line number Diff line change
@@ -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<HttpSendResult> {
// 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,
};
},
};
}
4 changes: 2 additions & 2 deletions core/src/lib/localScriptTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 42 additions & 12 deletions docs/hunt.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,18 @@ Add `--ui` to watch the attack tree unfold in a live dashboard.

### Target

| Option | Description |
| ---------------------------- | --------------------------------------------------------------------------------- |
| `--endpoint <url>` | Target HTTP endpoint (required) |
| `--objective <text>` | Attack objective |
| `--objective-file <path>` | Read objective from file |
| `--target-key-env <var>` | Env var with target API key |
| `--target-key <key>` | Target API key directly |
| `--name <name>` | Display name for target |
| `--target-model <id>` | Model value in requests |
| `--stateless` / `--stateful` | History handling mode |
| `--session-field <name>` | Body field for the session id (client-owned, stateful) |
| `--target-config <path>` | JSON file with a run-style `target` block; enables server-owned & header sessions |
| Option | Description |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `--endpoint <url>` | Target HTTP endpoint (required unless a local-script target is given via `--target-config`) |
| `--objective <text>` | Attack objective |
| `--objective-file <path>` | Read objective from file |
| `--target-key-env <var>` | Env var with target API key |
| `--target-key <key>` | Target API key directly |
| `--name <name>` | Display name for target |
| `--target-model <id>` | Model value in requests |
| `--stateless` / `--stateful` | History handling mode |
| `--session-field <name>` | Body field for the session id (client-owned, stateful) |
| `--target-config <path>` | JSON file with a run-style `target` block; enables server-owned & header sessions, and local-script targets |
Comment on lines +29 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

--endpoint description omits the --target-config HTTP case.

The description says --endpoint is "required unless a local-script target is given via --target-config", but for HTTP targets the endpoint can also be supplied inside the --target-config file, making the --endpoint flag optional in that case too (see endpoint: opts.endpoint ?? baseTarget.endpoint in hunt.ts). The current wording could mislead users into thinking --endpoint is always required for HTTP targets.

📝 Suggested wording fix
-| `--endpoint <url>`           | Target HTTP endpoint (required unless a local-script target is given via `--target-config`)                 |
+| `--endpoint <url>`           | Target HTTP endpoint (required for HTTP targets unless provided via `--target-config`; not used for local-script targets) |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| Option | Description |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `--endpoint <url>` | Target HTTP endpoint (required unless a local-script target is given via `--target-config`) |
| `--objective <text>` | Attack objective |
| `--objective-file <path>` | Read objective from file |
| `--target-key-env <var>` | Env var with target API key |
| `--target-key <key>` | Target API key directly |
| `--name <name>` | Display name for target |
| `--target-model <id>` | Model value in requests |
| `--stateless` / `--stateful` | History handling mode |
| `--session-field <name>` | Body field for the session id (client-owned, stateful) |
| `--target-config <path>` | JSON file with a run-style `target` block; enables server-owned & header sessions, and local-script targets |
| Option | Description |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `--endpoint <url>` | Target HTTP endpoint (required for HTTP targets unless provided via `--target-config`; not used for local-script targets) |
| `--objective <text>` | Attack objective |
| `--objective-file <path>` | Read objective from file |
| `--target-key-env <var>` | Env var with target API key |
| `--target-key <key>` | Target API key directly |
| `--name <name>` | Display name for target |
| `--target-model <id>` | Model value in requests |
| `--stateless` / `--stateful` | History handling mode |
| `--session-field <name>` | Body field for the session id (client-owned, stateful) |
| `--target-config <path>` | JSON file with a run-style `target` block; enables server-owned & header sessions, and local-script targets |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/hunt.md` around lines 29 - 40, Update the `--endpoint` option
description in the options table to state that the flag is required only when no
endpoint is provided by `--target-config`, while preserving the exception for
local-script targets configured there.


### Session handling

Expand Down Expand Up @@ -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 |
Expand Down
38 changes: 33 additions & 5 deletions runners/cli/src/commands/hunt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,20 @@ function parseHeaders(raw?: string[]): Record<string, string> | undefined {
// and resolves the API key from `apiKeyEnv` (the file holds the var name).
function mapAgentTargetToAutonomous(t: ReturnType<typeof parseAgentTarget>): 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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
Loading