Registering a target

POST /api/v1/targets (targets:write) takes { "target": "https://staging.example.com" } and returns the target plus a verification token. Registration normalizes the URL and rejects private addresses before anything is stored.

Only explicitly registered hosts are in scope. A workspace can also allowlist hosts it owns, which skips the verification step for those hosts.

Verifying control

POST /api/v1/targets/:id/verify (targets:write) checks the token you served at /.well-known/offense-verify.txt.

What the check does and does not do:

  • resolves the host through DNS-over-HTTPS at cloudflare-dns.com,
  • rejects private answers, including after redirects,
  • caps the response at 4 KB and times out after 5 seconds,
  • rejects redirects instead of following them.

Verification covers only the hosts you registered. It is not a statement that you are authorized to test anything else.

Identities

PUT /api/v1/targets/:id/identities (targets:write) attaches the two logins used for the tenant-isolation check. Each identity belongs to a different organization and carries one sample record.

  • Passwords are encrypted with AES-GCM under CREDENTIALS_KEY (base64, 32 bytes) and are readable only by the runner through a runner-only credential endpoint.
  • They never enter findings, evidence, the run log, or a model prompt.
  • For SSO, MFA or CAPTCHA, supply a Playwright storageState instead. The app rejects identical states for both identities, validates cookie expiry and size, and encrypts each state. An expired session produces an explicit /app/onboarding/accounts?error=session state.
  • Revoking a credential removes it from future runs. It is not retroactively removed from evidence already collected.

Swarms

A swarm launches several independent assessments against the same verified target and returns one combined report.

  • In the browser, choose Agents on the review screen. 1 launches a normal assessment; 2 or more launches a swarm. Each agent gets a distinct focus area and testing approach from src/swarm.js, so the group does not spend every budget on the same boundary.
  • MAX_SWARM_AGENTS caps the size (default 100). The per-agent budget is min(ASSESSMENT_BUDGET_MICROS, available / count), and MIN_ASSESSMENT_BUDGET_MICROS is required for each member.
  • Each member reserves and settles its own budget, so stopping a swarm refunds every unused member reservation.
  • The combined report at /app/swarms/:id merges duplicate findings across agents, shows which agents reproduced each finding, lists each assignment, and links back to the individual evidence bundles.

Over the API (POST /api/v1/swarms, scope assessments:create):

{ "count": 3, "budgetMicros": 2000000, "instruction": "…",
  "model": "…", "idempotencyKey": "…" }

budgetMicros is per agent. The response returns the combined findings and a reportUrl. GET /api/v1/swarms/:id reads it; POST /api/v1/swarms/:id/stop (assessments:cancel) stops every active member.

Swarm members still use the normal durable job path. MAX_ACTIVE_SANDBOXES limits an ordinary batch; a swarm may create more queued members than that, while MAX_GLOBAL_ACTIVE and the queue continue to bound real concurrency.

Restricting a key to one host

An API key can be limited to a set of hostnames via allowedTargets. Any route that addresses a target, sandbox, assessment or swarm by id is checked against that set before the handler runs:

{
  "error": "api_key_target_forbidden",
  "allowedTargets": ["staging.example.com"],
  "message": "This key is restricted to staging.example.com."
}

An empty allowlist means the key reaches every target the workspace owns. POST /api/v1/sandboxes/:id/config is exempt because it is the call that attaches a target in the first place — the target it sets is still checked.

Scheduled loops

POST /api/v1/loops (loops:write) runs an assessment on a schedule.

  • intervalMinutes has a 5-minute floor and defaults to 1440.
  • concurrency is 1–10.
  • 409 target_not_ready if the loop's target is not verified.
  • POST /api/v1/loops/:id/stop disables it.

A loop is dispatched by the Worker cron (*/5 * * * *). The window is claimed with a compare-and-set on next_run_at before any work, so two ticks cannot run the same window, and budget and per-loop concurrency are checked first. Every skip is recorded as last_error rather than dropped.

A loop stores and shows the source addresses it probes from (OFFENSE_PROBE_ADDRESSES, comma-separated) and any declared test windows.

Run web/db/migrations/0035-loop-schedules.sql before deploying loops.