Skip to content

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, an execute() (the only method that performs I/O), and a preview() (renders the would-be request for dry-run, secrets masked). GET /api/actions serves 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.

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.

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.

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 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, severity or an owner — not even false_positive. A case is closed by a human through the ordinary status API.
  • No dispatch. suggested_runbook is 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 as suggested_runbook_rejected.
  • Verified citations only. evidence_refs are matched against the ids in the context that was actually sent; anything else is listed separately as unverified_evidence_refs so 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.invocation with a refusal_code, or ai.refusal when 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.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 managed vectorflow_lake sink + 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).

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 (default Authorization) + config.auth_prefix + the secret as the header value.
  • oauth2_client_credentials: config.token_url (https), config.client_id and an optional config.scope; the secret is the client secret. Warden posts the client-credentials grant to the token URL, caches the bearer token until 60 seconds before expires_in, and on a 401 fetches a new token and retries the request once. Previews show Authorization: ***REDACTED*** and never mint a token; token errors name the endpoint host, HTTP status and OAuth error code, never the secret. Connection test mints a fresh token. mailbox_graph uses the same transport.

Pack actions with transport: http inherit both modes, so a pack never carries a credential.

Documented defensive-response recipes:

Host isolation is a managed response, not a raw HTTP call: see response.host_isolate under Enforcement adapters and runbooks/edr-isolate-host.yml.

- 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.

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 }}"

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.

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"]

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_action writes the binding into the effect’s parameters_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 to degraded, never active.

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.

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 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.

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.