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.
Where runbooks live
Section titled “Where runbooks live”- Local: any
*.yml/*.yamlunder therunbooks/directory (loaded on startup and viaPOST /api/runbooks/sync). - Git: set
WARDEN_RUNBOOKS_GIT_URL(+WARDEN_RUNBOOKS_GIT_BRANCH,WARDEN_RUNBOOKS_GIT_PATH);POST /api/runbooks/syncclones/pulls, validates, and upserts every runbook. A runbook’sversionmust never decrease on sync. The URL must behttps://; scp-style (git@host:repo),file://and local paths are refused. Upper- or lowercaseHTTPS_PROXY,ALL_PROXYandNO_PROXYapply to the clone. The proxy must behttp://orhttps://; a SOCKS proxy makes the sync fail rather than connect directly. - A runbook whose steps call
chad.*orvectorflow.*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.
Anatomy
Section titled “Anatomy”id: phishing-triage # slug ^[a-z0-9-]{3,64}$, uniquename: Phishing Triagedescription: Enrich and triage phishing alertsversion: 3 # author-bumped integer; sync rejects a decreaseenabled: truetrigger: 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 }Triggers
Section titled “Triggers”-
alert— fires on CHAD/webhook intake for every enabled runbook whoseconditionsmatch (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 ofcronorinterval_s:trigger:type: schedulecron: "*/15 * * * *" # OR:interval_s: 3600The worker computes
next_fire_atwhen 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.
Templating
Section titled “Templating”Steps are rendered with a sandboxed Jinja2 environment. Context:
trigger— the alert payload + flattenedmatched_logfields (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.
Step kinds & flow
Section titled “Step kinds & flow”action(default) — invokes a registry action (seedocs/actions.md).branch— evaluateswhen→ bool; downstream steps taggedwhen_branch: "true"|"false"run only on the matching edge (others are loggedskipped).- Edges are declared with
needs: [step_id, …]. on_error:fail(fail the run),continue(mark the steperror, proceed), orgoto:<step_id>(jump back). A backwardgotoresets the target and everything downstream of it and re-runs them; a per-target visit counter caps re-entries atWARDEN_MAX_STEP_VISITS(default 3) → the run fails withrunbook.loop_cap_exceeded.approvalon a step pauses the whole run (waiting_approval) until an approver (any ofroles) approves/denies, or the deadline expires. The deadline istimeout_hhours after the gate opens. Withouttimeout_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 endsexpired(runbook.approval_expired).autonomous: trueon 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’sdangertier, which stays admin-gated on every origin.for_each: "<jinja → list>"on an action step runs that action once per list item, exposingitemandloop.index/loop.countto itswithtemplates. The step output aggregates to{ items: [...], count, errors: [...] }(downstream:steps.<id>.output.items[k]) as ONE hash-chainedaction_logrow for the whole loop. Per-item failures follow the step’son_error—continuecollects them intoerrors(run proceeds),failfails the run on the first failing item.
Execution & audit
Section titled “Execution & audit”- 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-chainedaction_logorder stable and makes approval-pause resume replay exact. (WARDEN_RUNBOOK_STEP_CONCURRENCYis reserved for a future bounded- parallel executor and is currently a no-op.) - Every step appends a hash-chained
action_logrow (never updated). Verify a run’s chain atGET /api/runs/{id}(badge) orGET /api/runs/chain/verify. - Dry-run renders every step’s
withand calls each action’spreview()— zero side effects — and recordsdry_runrows. Use it from the builder orPOST /api/cases/{id}/runbooks/{slug}/runwith{"dry_run": true}.
Validation
Section titled “Validation”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).
The visual builder
Section titled “The visual builder”/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.
Draft with AI
Section titled “Draft with AI”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 withai_draft_unknown_actionand 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_runbookand 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.