Approvals

How OpenWAF Tasks keeps writes behind a person. The read-only guard, typed steps, the payload hash, the act phase, expiry, policies and the webhook.

On this page

A task never writes on a site by itself. It proposes an action, a person approves that exact action, and only then does the runner perform it, without your code or the model.

The read-only guard

While a run prepares, the runner holds its own DevTools connection to the browser, attached to every page, frame and worker. It fails every request whose method is not GET, HEAD or OPTIONS, and logs it as a blocked event (counted in the run's blocked_requests). Your script and the agent cannot turn it off: it is a separate connection they never see.

A form submission, a fetch with POST or a DELETE from the page all fail in this phase. Pages that need a POST just to be read do not work in a task.

Steps

An action is a list of 1 to 50 typed steps. The first one must be goto. Selectors are Playwright selectors (css=, text=, role=…; CSS pierces open shadow roots).

opFieldsWhat it does
gotourlOpen an https:// URL on one of the task's allowed domains
clickselectorClick the element
fillselector, textSet the value of an input or text area
typeselector, textType the text key by key
presskey, optional selectorPress a key, for example Enter
selectselector, valueChoose an option of a <select>
checkselectorTick a checkbox or radio button
wait_forselector or textWait until it appears
waitmsWait 1 to 10000 ms
assert_urlurlStop with an error unless the page URL starts with it (https://, allowed domain)
assert_texttextStop with an error unless the page shows it

Texts are up to 10000 characters, selectors and values up to 2000, URLs up to 4000. A proposal also carries a one-line summary (up to 300 characters), the target (the https:// URL the write lands on; its host is the action's domain) and a screenshot of the page as the task saw it.

Use assert_url and assert_text so an action fails rather than doing something else when the page has changed since it was proposed.

The payload hash

OpenWAF validates the steps against the task's allowed domains and stores payload_sha256: the hex SHA-256 of the canonical JSON of {"steps": [...]}, with fields in the order of the table above (op, url, selector, text, key, value, ms), empty fields left out, no HTML escaping and no trailing newline.

To approve, you send that hash back with POST /v1/orgs/{org}/task-approvals/{id}/approve. If it differs from the stored one the answer is 409 payload_changed: you approve what you saw, nothing else. The runner recomputes the hash again before acting.

The act phase

After approval the runner starts a new browser with the task's key and profile, checks the hash and replays the steps itself. Neither your script nor the model runs. Requests with write methods are allowed only to the action's domain and its subdomains. The approval gets executed_at, a final screenshot and a result: ok, final_url and error.

If a per-domain limit does not allow the action yet, the approval stays approved with not_before and runs then.

States

ApprovalMeaning
pendingWaiting for a person
approvedApproved; waiting for the runner or for not_before
executedThe steps ran to the end
failedA step failed; result.error says which
rejectedA person rejected it
expiredNobody decided in time

A run with proposals stays awaiting_approval until none is pending. Then it ends succeeded if an action ran, failed if one failed, rejected if all were rejected or expired if they expired.

Expiry

An approval expires after approval.expire_minutes: 120 minutes (2 hours) by default, between 10 and 1440.

Policies

approval.policyMeaning
requiredEvery action waits for a person. The only policy you can set.
relaxedProposals to relaxed_domains are approved automatically. Only an operator sets it, with a reason, and only for internal, non-public targets.

Webhook

Give the task a webhook.url (a public https:// URL) to get approval.requested and run.finished events. The secret (owts_…) is shown once when you set the webhook; "rotate_webhook_secret": true in a PATCH makes a new one.

Each delivery is a POST with a JSON body and these headers:

X-OpenWAF-Signature: t=1761735600,v1=5d2f…
X-OpenWAF-Event: approval.requested
X-OpenWAF-Delivery: 0b9d5c3e-…
{"id": "0b9d5c3e-…", "type": "approval.requested", "created_at": "2026-10-29T10:00:00Z", "org_id": "…",
 "task": {"id": "…", "name": "guestbook"},
 "run": {"id": "…", "number": 12, "status": "awaiting_approval", "error": "", "finished_at": null},
 "approval": {"id": "…", "status": "pending", "summary": "Sign the guestbook…", "target": "https://example.com/guestbook",
              "domain": "example.com", "payload_sha256": "9c1e…", "expires_at": "2026-10-29T12:00:00Z"}}

approval is null in run.finished. A 2xx answer counts as delivered; otherwise the delivery is retried 3 times, after 1, 5 and 30 minutes. X-OpenWAF-Delivery stays the same across retries, so you can drop duplicates.

t is the Unix time of the delivery and v1 the hex HMAC-SHA256, keyed with the secret, of t, a dot and the raw body. Recompute it, compare in constant time and reject deliveries older than 5 minutes:

verify.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(header, rawBody, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const got = Buffer.from(parts.v1 ?? "", "hex");
  return got.length === want.length && timingSafeEqual(got, want);
}

Last updated:

↑↓ to navigate↵ to openesc to close