Action library
Source of truth:
warden/docs/actions.md(synced at build time — edit the original, not this copy). Actions are the typed, reusable operations a runbook step invokes. Each declares a connection kind, a Pydantic input/output schema, anexecute()(the only method that performs I/O), and apreview()(renders the would-be request for dry-run, secrets masked).GET /api/actionsserves the full registry (name, JSON-Schemas, connection kind, implemented) — the builder’s palette and the validator read it.
Outbound HTTP uses the SSRF guard in app/services/notifier.py. It refuses
link-local, private and metadata addresses except for operator-configured suite
peer hosts such as CHAD_SERVICE_URL. Warden’s own calls to identity pass
trusted_origin=SUITE_EXCHANGE_URL, so only that exact origin skips the check
for them; action and connection URLs can’t reach identity’s internal address.
Development may allow other private targets with
ALLOW_INTERNAL_WEBHOOK_IPS=true.
Connections
Section titled “Connections”A connection is a configured integration target: { kind, name, base_url, config, secret }. The secret is Fernet-encrypted at rest (WARDEN_SECRET_KEY)
and never returned by the API. Manage them under Settings → Connections
(manage_connections), and test with POST /api/connections/{id}/test.
A chad connection takes no secret. Warden authenticates every CHAD call with a
short-lived token from the Veltro workload exchange, scoped to that call, so no
CHAD API key is created or stored. Creating a chad connection with a secret is
refused.
Kinds: chad, vectorflow, slack, jira, webhook, http, ssh.
Authored actions
Section titled “Authored actions”Settings → Action Builder lets users with manage_connections create versioned
custom.* HTTP and SSH actions without deploying code. Authored actions appear in
GET /api/actions and the runbook palette with the same input/output JSON Schema
and execution contract as built-ins.
Constrained HTTP definitions fix the method and the declared path, header, query, and JSON-body fields; only their values may reference declared inputs. Constrained SSH definitions fix a bare command and one template per argument, with every rendered argument quoted independently. A definition can choose a default connection, while a runbook step may override that binding; the selected connection continues to own the endpoint, authentication secret, and SSH host-key policy. Preview resolves the masked request or command without I/O; Test connection runs only the connection’s non-effectful probe.
Definitions also declare timeout, bounded retry behavior, output extraction and redaction, effect/risk/target metadata, idempotency, and optional compensation. Arbitrary request/shell mode is a separate global-administrator tier: saving it requires the danger acknowledgement, every live execution requires admin approval (including manual runs), and execution adds a dedicated audit event.
The 34 built-in actions
Section titled “The 34 built-in actions”Generated from app.services.action_registry.list_actions() (builtin only; custom.* actions are operator-authored). Count: 34.
| Action | Connection | Risk | Description |
|---|---|---|---|
ai.agent |
mcp | read |
Bounded AI agent loop over allowlisted tools; any write intent stops the loop and opens an approval gate. |
ai.triage |
—(internal) | read |
Ask the configured AI provider for a triage verdict on the bound case. Records the verdict and its rationale; never changes case state. |
case.update |
—(internal) | write |
case.update |
chad.add_exception |
chad | write |
Request a rule exception on a CHAD detection rule — CHAD may apply it or file it for review. |
chad.comment_alert |
chad | notify |
chad.comment_alert |
chad.get_alert |
chad | read |
chad.get_alert |
chad.snooze_rule |
chad | write |
chad.snooze_rule |
crowdsec.block |
ssh | write |
Create a short-TTL CrowdSec ban decision through the host’s own cscli (LAPI machine authentication) and return its exact decision id |
crowdsec.status |
ssh | read |
Read one CrowdSec LAPI decision back by its exact id (via cscli). An absent id is the typed ‘no such decision’ state, never a failure |
crowdsec.unblock |
ssh | write |
Delete one CrowdSec LAPI decision by its exact id (cscli) and prove it is gone: deleting an already-absent id is a clean idempotent no-op |
enrich.abuseipdb |
http | read |
enrich.abuseipdb |
enrich.virustotal |
http | read |
enrich.virustotal |
http.request |
http | arbitrary |
http.request |
mcp.call |
mcp | arbitrary |
Invoke one operator-allowlisted tool on an external MCP server. |
notify.email |
email_smtp | notify |
notify.email |
notify.jira |
jira | notify |
notify.jira |
notify.msteams |
msteams | notify |
notify.msteams |
notify.opsgenie |
opsgenie | notify |
notify.opsgenie |
notify.pagerduty |
pagerduty | notify |
notify.pagerduty |
notify.servicenow |
servicenow | notify |
notify.servicenow |
notify.slack |
slack | notify |
notify.slack |
notify.webhook |
webhook | notify |
notify.webhook |
response.domain_block |
—(internal) | write |
response.domain_block |
response.domain_status |
—(internal) | read |
Read one domain’s denylist state back from the bound adapter. |
response.domain_unblock |
—(internal) | write |
response.domain_unblock |
response.host_isolate |
—(internal) | write |
response.host_isolate |
response.host_release |
—(internal) | write |
response.host_release |
response.host_status |
—(internal) | read |
Read one host’s containment state back from the bound adapter. |
response.ip_block |
—(internal) | write |
response.ip_block |
response.ip_status |
—(internal) | read |
Read one target’s containment state back from the configured adapter. |
response.ip_unblock |
—(internal) | write |
response.ip_unblock |
ssh.command |
ssh | arbitrary |
ssh.command |
vectorflow.await_confirmation |
vectorflow | read |
vectorflow.await_confirmation |
vectorflow.deploy_request |
vectorflow | write |
vectorflow.deploy_request |
In a run bound to a case, notify.pagerduty defaults dedup_key and notify.opsgenie defaults alias to warden-case-<case id>, so repeat pages update one incident or alert. The page is linked to the case (page_links); an explicit key is kept and linked too.
ai.triage is advisory, never a decision
Section titled “ai.triage is advisory, never a decision”ai.triage asks the configured AI provider (Settings → AI; off until an operator
configures one) for a verdict on the run’s bound case and records it on
Case.ai_triage plus one ai_triage timeline event. It is classified read /
low because that is all it does:
- No case transitions. The verdict never sets
status,disposition,severityor an owner — not evenfalse_positive. A case is closed by a human through the ordinary status API. - No dispatch.
suggested_runbookis a slug from the enabled-runbook list, returned for a human to trigger; the normal trigger, risk gate and approvals still apply. A slug the model invents is discarded and reported assuggested_runbook_rejected. - Verified citations only.
evidence_refsare matched against the ids in the context that was actually sent; anything else is listed separately asunverified_evidence_refsso an invented citation is never shown as real. A suggested slug is checked against every enabled runbook, not just the (bounded) list the prompt showed, so a real runbook is never discarded as invented. - Named refusals. The kill switch, an unconfigured/disabled provider, a denied
data class, non-JSON output, or an answer that fails the triage schema each fail
the step with a typed code (
ai_kill_switch,ai_not_configured,ai_disabled,ai_data_class_denied,ai_structured_output_invalid,ai_triage_output_invalid) and are audited (ai.invocationwith arefusal_code, orai.refusalwhen no provider was reached).
The prompt carries the cases, alerts and events data classes (case fields,
its linked alerts and their matched events, observables, TI verdicts already
recorded by enrichment steps, related cases and how similar cases were
dispositioned). All of them must be in the provider’s allow_data_classes.
A case policy may set auto_ai_triage to queue one advisory triage for the case
it opens; the worker runs it out of band, so intake never blocks on a model call.
Analysts record their own verdict beside the AI’s (POST /api/cases/{id}/ai-triage/feedback), and the weekly SOC metrics report publishes
the resulting agreement rate.
CHAD exceptions may be reviewed, not applied
Section titled “CHAD exceptions may be reviewed, not applied”chad.add_exception requests an exception; it does not guarantee an immediate
change to the deployed rule. CHAD may apply it, file it as a tuning proposal that
a CHAD reviewer must approve (veltro #86), or refuse to write directly. The step
records which happened in outcome, so a run’s action log never claims a write
CHAD did not perform:
outcome |
Meaning | Identifier |
|---|---|---|
applied |
CHAD wrote the exception to the deployed rule. | exception_id |
pending_review |
CHAD filed a proposal. Nothing is suppressed until a CHAD reviewer approves it. | proposal_id |
indeterminate |
CHAD accepted the call (2xx) but named no outcome this action recognises — an older CHAD, or a newer response shape. Never read as applied. |
exception_id if CHAD returned one |
A refusal is a non-2xx and fails the step with CHAD’s own reason (detail),
not a bare HTTP status. Author downstream steps against outcome, not against
the step merely succeeding — a pending_review step means the tuning is still
waiting on a second human in CHAD, on top of any Warden approval gate.
Standard-Webhooks signing (notify.webhook)
Section titled “Standard-Webhooks signing (notify.webhook)”Headers: webhook-id, webhook-timestamp, webhook-signature: v1,<base64> where
the signature is HMAC-SHA256(secret, "{id}.{timestamp}.{body}") over the exact
serialized body. whsec_-prefixed secrets are base64-decoded; otherwise the raw
key bytes are used. This matches VectorFlow’s outbound webhook scheme, so downstream
verifiers are shared across the suite.
VectorFlow reach-back
Section titled “VectorFlow reach-back”vectorflow.deploy_request raises an ingest-plane deploy-request against
VectorFlow (POST /api/v1/deploy-requests, Authorization: Bearer vf_live_…):
quarantine_source/drop_source— disable the named source in a pending pipeline version.sample_source— splice a sample transform (sample_rate).fork_to_lake— add a managedvectorflow_lakesink + edge from the source.
VectorFlow creates a PENDING deploy-request capturing the mutated config; a
VectorFlow operator approves it there. vectorflow.await_confirmation polls
GET /api/v1/deploy-requests?status=DEPLOYED&pipelineId=… until the release lands
(polling is authoritative — VF gates deploy_completed webhooks on a matching
AlertRule). Gate the deploy_request step behind an approval so containment is
double-checked (WARDEN approver and VectorFlow operator).
Containment recipes (http.request)
Section titled “Containment recipes (http.request)”http.request is a generic signed call. The connection holds base_url and one
of two auth modes, picked by config.auth:
header(the default):config.auth_header(defaultAuthorization) +config.auth_prefix+ the secret as the header value.oauth2_client_credentials:config.token_url(https),config.client_idand an optionalconfig.scope; the secret is the client secret. Warden posts the client-credentials grant to the token URL, caches the bearer token until 60 seconds beforeexpires_in, and on a 401 fetches a new token and retries the request once. Previews showAuthorization: ***REDACTED***and never mint a token; token errors name the endpoint host, HTTP status and OAutherrorcode, never the secret. Connection test mints a fresh token.mailbox_graphuses the same transport.
Pack actions with transport: http inherit both modes, so a pack never carries
a credential.
Documented defensive-response recipes:
EDR host isolation
Section titled “EDR host isolation”Host isolation is a managed response, not a raw HTTP call: see
response.host_isolate under Enforcement adapters
and runbooks/edr-isolate-host.yml.
IdP user disable
Section titled “IdP user disable”- id: disable_user action: http.request approval: { roles: [approver, admin], timeout_h: 4, message: "Disable {{ trigger.matched_log.user.name }}?" } with: method: PATCH path: "/users/{{ trigger.matched_log.user.id }}" json_body: { accountEnabled: false }Connection: http kind, base_url = your IdP (e.g. Graph/Okta) API root, secret =
the IdP API token. Both are approval-gated — WARDEN never disables or isolates
without a recorded human decision in the hash-chained action log.
Cloudflare IP block
Section titled “Cloudflare IP block”http.request also expresses a Cloudflare block — generic primitive, vendor as
recipe, no new code. Connection: http kind, base_url =
https://api.cloudflare.com/client/v4, config.auth_prefix = "Bearer ", secret =
a Cloudflare API token. See runbooks/cloudflare-ip-block.yml.
- id: block_ip action: http.request connection: cloudflare approval: { roles: [approver, admin], timeout_h: 4 } with: method: POST path: "/accounts/{{ inputs.account_id }}/firewall/access_rules/rules" json_body: mode: block configuration: { target: ip, value: "{{ trigger.matched_log.source.ip }}" } notes: "Blocked by WARDEN {{ case.key }}"CrowdSec (bundled read pack)
Section titled “CrowdSec (bundled read pack)”The CrowdSec pack (content/packs/crowdsec-2.0.0.yml) reads the LAPI with a
bouncer key over a generic http connection: base_url is the LAPI root,
the secret is a bouncer API key (cscli bouncers add) and config is
{"auth_header": "X-Api-Key"}. A bouncer key can only call GET /v1/decisions.
Adding a decision (POST /v1/alerts) and deleting one (DELETE /v1/decisions)
take a machine JWT, and the LAPI skips its allowlist check for an alert pushed
with decisions. So the pack writes nothing; blocks go through the cscli
built-ins below, which check the allowlist themselves (veltro#1450).
| Action | Effect | What it does |
|---|---|---|
custom.crowdsec_check_connection |
read | GET /v1/decisions?scope=ip&value=192.0.2.1 (an RFC 5737 documentation address, so the answer is tiny): proves the key is accepted and the LAPI reachable |
custom.crowdsec_ban_status |
read | GET /v1/decisions?scope=…&value=…&type=ban: exact value, unexpired decisions only. A ban is enforced; the LAPI’s 200 with a null body is not. This is what bouncers enforce, so a runbook can confirm a cscli block reached them |
For the managed response, bind the block/ip adapter to crowdsec.block,
crowdsec.unblock and crowdsec.status with the ssh connection to the CrowdSec
host as the binding’s connection_name. The managed invocation fills
crowdsec.block’s ttl from the effect’s TTL (3600s).
CrowdSec (real LAPI, via cscli — veltro #622)
Section titled “CrowdSec (real LAPI, via cscli — veltro #622)”A real CrowdSec LAPI creates decisions through machine-authenticated alerts. The
enforcement primitive is a set of built-in actions that run cscli on the
CrowdSec host over the shipped constrained SSH action path
(backend/app/actions/crowdsec_lapi.py): cscli authenticates to its local LAPI
with the host’s own machine account, so Warden never holds the LAPI credential
and no second HTTP transport exists. The connection is an ssh connection
(host/username/host-key policy in config, private key as the secret).
| Action | Effect | What it does |
|---|---|---|
crowdsec.block |
mutate (bounded) | cscli decisions add --ip/--range <value> --duration <ttl> --reason <reason> (machine-authenticated), then reads the EXACT decision id back from cscli decisions list -o json and returns it. The read-back is correlated to this invocation by a unique per-invocation marker carried in the reason (never by largest-id ordering), so an older or concurrent matching ban is not mistaken for the one just created. Fails if no matching non-simulated ban decision carrying this invocation’s marker appears in the LAPI read-back — a successful SSH call is never containment by itself |
crowdsec.status |
read | Reads one decision back by its exact id via cscli decisions list -o json; an absent id is the typed not_found state (“no such / expired decision”), never a failure |
crowdsec.unblock |
mutate (bounded) | cscli decisions delete --id <id> — deletes ONLY the returned decision id, then re-lists and fails unless the exact id is proven gone. Deleting an already-absent id is an idempotent clean no-op |
Guardrails: the block requires a short explicit
TTL (validated at the action boundary — blank/zero/malformed/>30 d is
refused before any egress) and the bounded non-danger contract keeps the
default-deny risk gate semantics (autonomous in automatic runs). There is no --bypass-allowlist path: the flag is
never part of the constructed argv, so an allowlisted target is refused by cscli
itself, complementing the protected-target gate. Compensation metadata points
the response spine at crowdsec.unblock.
The real containment composition ships as
runbooks/crowdsec-real-temporary-block.yml (manual trigger; contain: true
blocks with a short TTL and confirms the exact id via status; contain: false
reverses using the recorded decision_id input). Deterministic tests
(backend/tests/test_crowdsec_lapi.py) drive all three actions through a
cscli-compatible LAPI client speaking the real cscli decisions list -o json
shape, plus the golden path through the shipped runbook. Live enforcement
against the veltro-dev Pangolin/CrowdSec testbed is the post-merge
verification surface.
Host commands (ssh.command)
Section titled “Host commands (ssh.command)”ssh.command runs a command on a host over SSH — the generic primitive behind
“iptables DROP on a host”. The connection is an ssh kind: config holds the
non-secret target (host, port (default 22), username, and a host-key policy —
either a pinned known_hosts string or accept_unknown_host_key: true), and the
secret is the PEM private key; base_url is unused.
The command runs as an argument list, never a shell string: command must be a
bare binary/path (^[A-Za-z0-9_./-]+$ — no spaces or shell metacharacters, enforced
at input validation so it also surfaces in dry-run) and every args entry is
shlex-quoted. A templated arg that renders to 1.2.3.4; rm -rf / becomes one inert
literal, not a shell injection. A non-zero exit status raises a step error;
preview() shows host + command + args and never the key. See runbooks/iptables-drop.yml.
- id: drop_ip action: ssh.command connection: firewall-host approval: { roles: [approver, admin], timeout_h: 4 } with: command: iptables args: ["-A", "INPUT", "-s", "{{ trigger.matched_log.source.ip }}", "-j", "DROP"]Block a list of IPs in one run with the for_each step modifier (see
runbook-authoring.md):
- id: drop_ips action: ssh.command connection: firewall-host for_each: "{{ trigger.matched_log.source.ips }}" with: command: iptables args: ["-A", "INPUT", "-s", "{{ item }}", "-j", "DROP"]Integration packs (export)
Section titled “Integration packs (export)”An integration pack is a single, self-contained YAML document that captures a
deployment’s authored custom.* actions so a GUI-built integration can be
reviewed and recreated elsewhere. GET /api/integration-packs/export
(manage_runbooks) serializes the catalog: metadata + semantic version,
compatibility requirements, the required connection kinds
(connection_kinds, the union of the actions’ transports — never a reference to
a configured connection row), and each action with its input/output schemas,
effect contract, reversal (effect.compensation_action, the same registry name
_check_compensation validates), redaction rules, and test fixtures.
The document ends with an integrity block: a SHA-256 over the canonical YAML
bytes without that block, so a future detached signature covers the same
bytes without changing the format. Export is deliberately credential-free — it
never reads connections or execution history, drops each definition’s
deployment-specific default connection binding, masks sample values for inputs
declared secret, and needs no registry or network.
Enforcement adapters (response.ip_block and friends)
Section titled “Enforcement adapters (response.ip_block and friends)”response.ip_block / response.ip_unblock / response.ip_status are the
managed containment actions: they own the contract the spine is built on
(normalized target, mandatory finite TTL, policy evaluation, protected targets,
approval gate, idempotent attempts, automatic compensation at expiry) and
delegate the vendor call to the enforcement adapter the active response
policy binds. An authored pack action can never declare a managed effect type
(app/schemas/custom_action.py excludes block/contain/disable_identity),
so this indirection is what lets a pack supply enforcement without synthesising
a containment contract.
response.host_isolate / response.host_release / response.host_status are
the same contract for a host: effect type contain, target type host, a
required TTL, and response.host_release as the automatic reversal at expiry.
They use the adapter bound for effect_type: contain, target_type: host (the
sentinelone-isolate, crowdstrike-contain or defender-isolate pack), never
an ip block adapter. The host is the EDR’s own id for the endpoint, or a
hostname; it is normalized like a policy host target (lowercase, IDNA, no
trailing dot), and anything outside letters, digits and . _ - : is refused
before policy evaluation, so a target can’t reshape the vendor request. A
protected host target is refused like a protected IP, but only when it is
written the way the bound adapter addresses the host: protecting
dc01.corp.example doesn’t protect DC01’s SentinelOne agent id, so list the
EDR id (or both) in protected_targets.
A 2xx from the vendor isn’t proof of containment. A read action can declare
effect.enforced_output (one of its outputs) and effect.enforced_values;
response.host_status and the other managed read-backs then report enforced
from that value (compared case-insensitively, surrounding whitespace ignored),
and anything else, including a missing agent, reads as not enforced. Every
shipped adapter’s read declares one: sentinelone-isolate network_status
disconnected, cloudflare-waf mode block, crowdsec (crowdsec_ban_status)
decision_type ban, pfsense alias_type host/network, aws-security-group
ingress_rules 0 (the block is the allow’s absence), okta-user-suspend
user_status SUSPENDED, and iptables-ssh/nftables-ssh exit_code 0
(iptables -C and nft get element answer by exit status). fortigate,
opnsense and paloalto-panos ship no read: FortiOS’s per-address check has no
public response shape, OPNsense’s alias search is a substring match, and PAN-OS
lists the tags an address holds, where “is the bound tag among them” is a
membership check a declared value can’t express, so response.ip_status reports
adapter_no_status_action for them. A bound pack read that declares no state
(one an older pack version left installed, say) is refused before the vendor
call as adapter_status_undeclared. An apply whose vendor answer says it
affected zero targets is a failure (adapter_affected_nothing), never an
active effect.
A read-back has no effect of its own, so for a vendor that reads by handle
(cloudflare-waf’s rule id) response.ip_status takes the handle from the
newest unresolved effect on the same target that the same adapter applied. With
no such effect the read is still adapter_input_unsatisfied:rule_id.
response.domain_block / response.domain_unblock / response.domain_status
do the same for a DNS denylist entry: effect type block, target type domain,
bound as effect_type: block, target_type: domain (the
cloudflare-gateway-dns pack). A domain target is one exact name, lowercased,
IDNA 2008 (UTS 46) encoded and stripped of its root dot; a wildcard, a single label, an IP
literal or anything with a path or space is refused. A protected domain covers
every name under it, so protecting corp.example also refuses
mail.corp.example but not notcorp.example. Because Gateway’s “Domain in
list” also blocks subdomains, a parent of a protected name is refused too:
protecting login.corp.example refuses corp.example. Gateway lists have no
per-value lookup, so the pack ships no read action and response.domain_status
reports adapter_no_status_action rather than an enforced it can’t prove.
Bind an adapter with PUT /api/response-policy (admin):
{ "protected_targets": [{"type": "ip_range", "value": "10.0.0.0/8"}], "ttl": {"min_seconds": 60, "max_seconds": 604800}, "automatic_grants": [], "adapters": [ { "effect_type": "block", "target_type": "ip", "adapter_id": "cloudflare-waf", "block_action": "custom.cloudflare_waf_block", "unblock_action": "custom.cloudflare_waf_unblock", "status_action": "custom.cloudflare_waf_status", "connection_name": "cloudflare-prod", "parameters": {"alias": "WARDEN_BLOCK"} }, { "effect_type": "contain", "target_type": "host", "adapter_id": "sentinelone", "block_action": "custom.sentinelone_isolate", "unblock_action": "custom.sentinelone_reconnect", "status_action": "custom.sentinelone_status", "connection_name": "sentinelone" } ]}parameters is adapter-specific, non-secret configuration (an alias name, an
nftables set, an AWS security-group id) filled into the bound action’s declared
inputs — credentials stay on the connection. The bound actions are filled from a
fixed vocabulary: value/target/ip (the normalized target), scope,
reason/comment/note, until/duration/ttl/ttl_seconds (derived from
the TTL; duration and ttl are both <seconds>s), and external_reference/rule_id/decision_id/handle (the provider
handle the apply recorded, for vendors that reverse by id). A required
declared input the invocation cannot supply is refused by name
(adapter_input_unsatisfied:<field>) rather than sent blank.
Two properties are enforced and tested:
- The adapter is frozen at proposal time.
evaluate_actionwrites the binding into the effect’sparameters_snapshot, which the snapshot digest covers, so re-binding the policy after an approval can never re-route the pending reversal to a different provider. - Every refusal is typed and can never report enforcement.
integration_not_configured(no adapter bound — the pre-adapter behaviour, unchanged),adapter_action_unavailable(the pack is not installed),adapter_action_arbitrary,adapter_connection_missing,adapter_connection_test_mode(a sandbox connection is refused outright: it must not be able to look like containment),adapter_input_unsatisfied:<field>,adapter_input_invalid. Each moves the effect todegraded, neveractive.
Idempotent provider answers are successes: a duplicate block (conflict) means
the target is blocked, and an unblock of something already gone (not_found,
zero deleted, or the shell’s absent-rule exit) means it is not. The ssh
transport raises on a non-zero exit, so the dispatcher recovers the status and
classifies it — but the exit code alone is never enough: iptables -D exits 1
both when the rule is already gone and when it was refused. Only the command’s
own “not there” wording is read as the idempotent answer: exit 1 with Bad rule (does a matching rule exist in that chain?) or Could not process rule: No such file or directory (an absent nft element; a missing set or table drops the
prefix and is a failure), or the AWS CLI’s service-error exit 254 with
InvalidPermission.NotFound/Duplicate (InvalidGroup.NotFound is a failure).
An explicit refusal (Permission denied, No chain/target/match by that name,
AccessDenied, …) always wins, and any other exit code is a real failure. That
reading applies only to a reversal or a read: on the apply path the same
wording means the opposite — nft add element answering No such file or directory is a missing set or table, so the block was never installed — and
every non-zero exit on a block is a failure. Reading a refusal as “already
reversed” would expire an effect while the block is still installed, which is
worse than failing.
Some vendors put the verdict in the body. PAN-OS answers its XML API with HTTP
200 and <response status="error">. The http transport reads an XML answer
into nested objects (attributes as @name, text as #text, a repeated child
as a list; a document that declares a DTD stays text), and an http action can
declare effect.outcome: the output holding the verdict (output), the
verdicts that mean success (success), the outputs carrying the vendor’s
message (message), and message fragments that mean “already there”
(already_present, read as conflict) or “already gone” (already_absent,
read as not_found). With an outcome declared, a 2xx with any other verdict is
a failure carrying the vendor’s message, and the vendor’s message, not the HTTP
status, decides conflict/not_found. paloalto-panos declares
status: success with PAN-OS’s “already exists, ignore” and “does not exist,
ignore unreg”; pfsense declares status: ok, so a 404 for an alias id that
no longer exists is a failed reversal, never already_reversed.
Some vendor objects are shared with the operator. A pfSense alias can hold the
operator’s own entries next to Warden’s, and the API can’t tell them apart:
appending an address that’s already listed stores it twice, and removing it
drops every copy. A mutate action can declare effect.ownership: read names
a read action that reports whether Warden’s own entry for the target is there,
and refusal is the reason a block gives when it isn’t. Before a block, Warden
runs that read. If Warden’s entry is there, the block is already_enforced and
nothing is written. If the bound status read finds the target present without
it, the block fails with refusal and nothing is written. Before a reversal,
Warden runs the same read. If Warden’s entry is gone, the reversal is
already_reversed and the operator’s entry stays. A read that fails stops the
write. pfsense marks its entries with a Warden block <ip> detail line and
reads them with custom.pfsense_warden_entry; a block of an address the
operator listed fails with address already in alias; Warden only manages addresses it added. Give Warden a dedicated alias anyway, so it’s clear which
entries the policy owns.
Some vendors refuse to undo what is already undone: Okta answers 400 E0000001
to unsuspending a user who isn’t suspended. A failed reversal is therefore
checked against the adapter’s read, when that read declares
effect.enforced_output: if the read succeeds and says the effect isn’t in
place, the reversal is already_reversed. A failed read, a read that still
finds the effect, or a read with no declared state leaves the failure standing.
Two output fields say different things and both are reported: enforced is
whether the target is blocked afterwards, enforcement_performed is whether
this call changed anything. response.ip_status therefore always reports
enforcement_performed: false (a read changes nothing) with state following
enforced — a read-back of an unblocked IP is a successful read that says so,
never a failed containment and never a claim of enforcement. The engine’s
managed “performed nothing ⇒ step error” rule applies only to the
block/contain/disable_identity contracts, so a read-back step is green on
a successful run.
Shipped adapter packs
Section titled “Shipped adapter packs”All are exercised by recorded fixtures in backend/tests/test_adapter_packs.py
and marked verified: fixture in the registry — no vendor is contacted by CI,
and none of them claims verified: live. The IP packs’ read-backs have contract
tests in backend/tests/test_ip_pack_readbacks.py, and the three OAuth2 packs have
contract tests in backend/tests/test_vendor_pack_contracts.py: each action
runs through the real http.request transport against the request and response
shapes in the vendor’s public API reference, cited next to each fixture.
| Pack | Transport | Target | Enforcement |
|---|---|---|---|
cloudflare-waf |
http (Bearer) | ip | Account IP access rule; reversal by the rule id the block returned |
crowdsec |
http (bouncer key) + ssh (cscli) |
ip | Reads with the pack; blocks with the crowdsec.* built-ins (a LAPI decision with a vendor-side expiry) |
iptables-ssh |
ssh | ip | iptables -I/-D/-C DROP rule |
nftables-ssh |
ssh | ip | Named-set membership (the drop rule stays the operator’s) |
pfsense |
http (REST API v2 pkg) | ip | Firewall alias membership: PATCH /api/v2/firewall/alias by the alias_id binding parameter with append/remove and apply; read via aliases?name=&address__contains=. Bind a dedicated alias: Warden only adds and removes its own entries and refuses an address the operator already listed |
opnsense |
http (Basic) | ip | alias_util add/delete; no read |
fortigate |
http (Bearer) | ip | Banned-user quarantine with FortiOS’s own expiry; no read |
paloalto-panos |
http (X-PAN-KEY) |
ip | User-ID registered-IP tag for a dynamic address group; no read |
aws-security-group |
ssh (AWS CLI) | ip | Revoke/restore the CIDR’s ingress permission; read counts the /32’s remaining tcp permissions for the bound port range |
okta-user-suspend |
http (SSWS) |
identity | User lifecycle suspend/unsuspend |
sentinelone-isolate |
http (ApiToken) |
host | Agent network disconnect/connect |
entra-user-disable |
http (OAuth2) | identity | Graph accountEnabled false/true on the user |
crowdstrike-contain |
http (OAuth2) | host | Falcon network containment and lift, by device id |
defender-isolate |
http (OAuth2) | host | Defender for Endpoint full isolation and release, by machine id |
cloudflare-gateway-dns |
http (Bearer) | domain | Append/remove on the Zero Trust Gateway list a DNS block policy references |
Composing a pack’s actions into a runbook is ordinary builder work once the pack
is installed — e.g. isolate → read back → reconnect for sentinelone-isolate.
Shipped runbooks under runbooks/ are deliberately not written against pack
actions unless they ship disabled with an install instruction, because
load_local_runbooks validates every action name at boot and a pack is
installed by the operator, not by the image.
Each pack’s description names the connection it needs (base URL, secret, and
the auth_header/auth_prefix or oauth2_client_credentials config the http
transport applies) and any
one-time setup the operator owns — the alias, the named set, the dynamic address
group, or the security group the adapter then only adds to and removes from.
The shipped pack registry (content/packs)
Section titled “The shipped pack registry (content/packs)”The image ships first-party packs in content/packs/, indexed by
content/packs/index.json (format: warden-pack-registry, v1). Settings →
Integration Packs lists them with their verification result and this
deployment’s install state; installing or upgrading runs the same preview,
consent and refusal gates as an uploaded pack.
Route (manage_runbooks) |
What it does |
|---|---|
GET /api/integration-packs/registry |
Every shipped pack: metadata, signature_state, verified, install state (not_installed/installed/upgrade_available/installed_ahead), and available: false + refusal when the pack cannot be installed as shipped |
POST /api/integration-packs/registry/{id}/preview |
Side-effect-free plan — an install preview when not installed, an upgrade preview (drift + blocking runs) when installed |
POST /api/integration-packs/registry/{id}/install |
apply_import with the operator’s consent (preview_token, accepted_overwrite_ids, acknowledge_danger) |
POST /api/integration-packs/registry/{id}/upgrade |
apply_upgrade with the same consent shape |
POST /api/integration-packs/registry/{id}/test |
“Test pack”: fixture-driven checks (see below) |
A pack is believed only after it is checked, in this order: the file’s sha256
must equal the checksum pinned in the index; the publisher signature must verify
when WARDEN_PACK_PUBLIC_KEY (base64 ed25519) is set — an unsigned or
non-verifying pack is then refused, the same fail-closed rule the VectorFlow
agent applies to a self-update; the document’s own integrity checksum must
verify; and its name/version must match the index. With no key pinned, packs
are checksum-verified only and report signature_state: "unverified" — never
verified. scripts/pack_index.py (verify, or --write to recompute) keeps the
index and the files in step, and tests/test_pack_registry_index.py fails CI on
drift, on an unindexed pack file, and on a verified: live claim that names no
evidence.
Test pack runs each action’s own fixtures through the action compiler and
reports one row per check (compiles, fixtures, template, connection,
execution). It follows the runbook debugger’s rule rather than a weaker one: an
action is executed only against a test-mode connection, a stored
ConnectionFixture response is checked against the action’s declared outputs
with no I/O at all, and an effectful action with no fixture is reported
skipped naming what it needs. A check that could not be proven is never a pass.
Import from OpenAPI
Section titled “Import from OpenAPI”POST /api/custom-actions/import/openapi (manage_connections) translates an
OpenAPI 3.0/3.1 document (JSON or YAML, capped by WARDEN_MAX_OPENAPI_KB) into
Action Builder drafts: typed inputs from path/query/header parameters and the
JSON request body, the path rewritten to {{ input.<name> }}, the server URL’s
path component folded into the template, and declared outputs lifted from the
first 2xx JSON response’s scalar properties.
The importer writes nothing. Each draft is created through the ordinary
POST /api/custom-actions path, so collision, compensation, administrator and
audit rules all still apply. Every draft is generated maximally guarded —
enabled: false, risk: arbitrary, effect_type: arbitrary with an unbounded
blast radius, max_attempts: 1 — because Warden cannot know what a vendor
operation does; the runbook engine therefore gates it behind approval until an
operator lowers it deliberately per action (suggested_risk is advice, not an
applied default). Authentication is never imported: no generated input is
secret, the connection owns the credential. Anything the constrained template
cannot express (a cookie parameter, a non-JSON body, an array/object-valued
parameter or body property, an operation past the import limit) is returned in
skipped with its reason instead of being dropped.