Skip to content

Authoring runbooks

Source of truth: warden/docs/runbook-authoring.md (synced at build time — edit the original, not this copy). A runbook is a directed acyclic graph (DAG) of steps that WARDEN executes in response to an alert or on demand. Runbooks are YAML in Git (detection-as-code for response) and are edited either as YAML or in the visual builder — the two views round-trip losslessly.

  • Local: any *.yml / *.yaml under the runbooks/ directory (loaded on startup and via POST /api/runbooks/sync).
  • Git: set WARDEN_RUNBOOKS_GIT_URL (+ WARDEN_RUNBOOKS_GIT_BRANCH, WARDEN_RUNBOOKS_GIT_PATH); POST /api/runbooks/sync clones/pulls, validates, and upserts every runbook. A runbook’s version must never decrease on sync. The URL must be https://; scp-style (git@host:repo), file:// and local paths are refused. Upper- or lowercase HTTPS_PROXY, ALL_PROXY and NO_PROXY apply to the clone. The proxy must be http:// or https://; a SOCKS proxy makes the sync fail rather than connect directly.
  • A runbook whose steps call chad.* or vectorflow.* actions needs that product installed. Without it, the local loader skips the file, and a runbook already stored is hidden and can’t run until the product is back. It isn’t deleted.
id: phishing-triage # slug ^[a-z0-9-]{3,64}$, unique
name: Phishing Triage
description: Enrich and triage phishing alerts
version: 3 # author-bumped integer; sync rejects a decrease
enabled: true
trigger:
type: alert # alert | manual | schedule
conditions: # optional; AND across keys; list = overlap; scalar = equality
severity: [high, critical]
rule_tags: [phishing]
inputs: # manual-run parameters (the builder renders a form)
- { name: source_id, type: string, required: false, description: Source to quarantine }
steps:
- id: enrich
action: chad.get_alert
with: { alert_id: "{{ trigger.alert_id }}" }
timeout_s: 60 # default 120
retries: 2 # default 0; exponential backoff base 2s
on_error: fail # fail (default) | continue | goto:<step_id>
ui: { x: 40, y: 40 } # canvas position; written by the builder, ignored by the engine
- id: is_malicious
type: branch # evaluates `when` to a bool, gates downstream when_branch steps
when: "{{ steps.enrich.output.verdict == 'malicious' }}"
needs: [enrich] # DAG edge(s): runs once all `needs` are ok/skipped
ui: { x: 40, y: 160 }
- id: notify
action: notify.slack
needs: [is_malicious]
when_branch: "true" # only on the branch's true edge (else skipped)
with: { message: "Case {{ case.key }}: malicious phishing" }
ui: { x: 240, y: 280 }
- id: quarantine
action: vectorflow.deploy_request
needs: [notify]
approval: # presence ⇒ human gate
roles: [approver, admin] # any-of; matches WARDEN RBAC roles
timeout_h: 24 # optional; no decision by the deadline ⇒ run status `expired`
message: "Quarantine source {{ inputs.source_id }}?"
with: { kind: quarantine_source, pipeline_id: "{{ inputs.pipeline_id }}", source_component_key: "{{ inputs.source_id }}" }
ui: { x: 240, y: 400 }
  • alert — fires on CHAD/webhook intake for every enabled runbook whose conditions match (AND across keys; a list value matches on overlap, a scalar on equality). The bound case is the newly created/correlated one.

  • manual — never auto-fires; run it from a case (POST /api/cases/{id}/runbooks/{slug}/run).

  • schedule — fires on a cron expression or a fixed interval, with no bound case (a maintenance run; case.* template refs are simply undefined). Set exactly one of cron or interval_s:

    trigger:
    type: schedule
    cron: "*/15 * * * *" # OR:
    interval_s: 3600

    The worker computes next_fire_at when the runbook is upserted and, on a ~30s tick, enqueues a run for each due + enabled schedule runbook, then advances the schedule to the next occurrence.

Steps are rendered with a sandboxed Jinja2 environment. Context:

  • trigger — the alert payload + flattened matched_log fields (dotted keys) for an alert trigger.
  • case — { key, id, severity, title, tags } of the bound case.
  • steps.<id>.output — an upstream step’s output object.
  • inputs — manual-run parameters.

Connection secrets are never placed in the Jinja context, so a rendered step’s action_log.rendered_input is secret-free by construction.

  • action (default) — invokes a registry action (see docs/actions.md).
  • branch — evaluates when → bool; downstream steps tagged when_branch: "true"|"false" run only on the matching edge (others are logged skipped).
  • Edges are declared with needs: [step_id, …].
  • on_error: fail (fail the run), continue (mark the step error, proceed), or goto:<step_id> (jump back). A backward goto resets the target and everything downstream of it and re-runs them; a per-target visit counter caps re-entries at WARDEN_MAX_STEP_VISITS (default 3) → the run fails with runbook.loop_cap_exceeded.
  • approval on a step pauses the whole run (waiting_approval) until an approver (any of roles) approves/denies, or the deadline expires. The deadline is timeout_h hours after the gate opens. Without timeout_h, and for gates the engine adds itself (the default-deny risk gate), it’s the default expiry from Veltro’s Settings > Approvals, or 24 hours when identity can’t be read. Past the deadline the approval can’t be decided and the run ends expired (runbook.approval_expired).
  • autonomous: true on an action step is the opposite: it declares that an automatic run executes this step WITHOUT a human approval even when the action’s blast radius would otherwise be gated (e.g. ssh.command, http.request). This is a loud, runbook-scoped, recorded decision — the rule is frozen into the run’s autonomy map and the step’s hash-chained action-log output — and it can never override the builder’s danger tier, which stays admin-gated on every origin.
  • for_each: "<jinja → list>" on an action step runs that action once per list item, exposing item and loop.index / loop.count to its with templates. The step output aggregates to { items: [...], count, errors: [...] } (downstream: steps.<id>.output.items[k]) as ONE hash-chained action_log row for the whole loop. Per-item failures follow the step’s on_error — continue collects them into errors (run proceeds), fail fails the run on the first failing item.
  • The worker executes steps in deterministic topological order, one step at a time (a Kahn sweep over needs). Execution is sequential, not parallel — this keeps the hash-chained action_log order stable and makes approval-pause resume replay exact. (WARDEN_RUNBOOK_STEP_CONCURRENCY is reserved for a future bounded- parallel executor and is currently a no-op.)
  • Every step appends a hash-chained action_log row (never updated). Verify a run’s chain at GET /api/runs/{id} (badge) or GET /api/runs/chain/verify.
  • Dry-run renders every step’s with and calls each action’s preview() — zero side effects — and records dry_run rows. Use it from the builder or POST /api/cases/{id}/runbooks/{slug}/run with {"dry_run": true}.

POST /api/runbooks/validate (body {yaml_text}) returns per-field errors: schema violations, unknown actions, actions for a product that isn’t installed, Jinja syntax, and DAG cycles (a cycle is only allowed as an explicit on_error: goto back-edge whose target is an ancestor).

/warden/runbooks/new and /warden/runbooks/{slug}/edit open the drag-and-drop canvas (xyflow), mirroring VectorFlow’s pipeline editor: drag actions from the palette, wire needs edges, configure each step in the drawer, and toggle to a Monaco YAML tab bound to the same model. Validate / Dry-run / Save from the toolbar. Saving writes a new runbook_versions row and bumps the version.

With an AI provider configured under Settings → AI, the builder toolbar offers Draft with AI: describe what the runbook should do and Warden asks the configured model for a runbook, then checks the answer before showing it to you.

What the check means in practice:

  • The prompt carries the live action registry — built-ins plus your custom.* actions — as the only actions the model may use. A draft that names anything else is refused with ai_draft_unknown_action and thrown away whole; you are never handed YAML that would fail on save.
  • A draft that does not pass the ordinary validation above (schema, DAG, Jinja) is refused with ai_draft_invalid_runbook and the per-field errors are listed.
  • A proposal arrives as a unified diff against the canvas you already have. Nothing is stored until you press Apply to canvas and then Save — the same manage_runbooks-gated save path, version bump and history row as any hand-authored change. There is no path by which the model writes a runbook.
  • Steps that change state outside Warden are asked for with an approval: block, and the proposal panel counts them. Review those steps yourself: an approval gate is a policy decision, not something to accept from a model unread.

A drafting request sends only your description and the action names — no case, alert or event data — so it needs no data-class consent. Every request is audited as ai.invocation with digests only (never your prompt text), and every refusal either as a refusal_code on that same ai.invocation row (the provider answered and the answer was refused) or as an ai.refusal row (a gate refused before any provider was reached). The Explain this run card on a run page works the same way over that run’s action log; it does need the alerts and cases data classes, and it never changes run state or resolves an approval.