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

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

| `op` | Fields | What it does |
|---|---|---|
| `goto` | `url` | Open an `https://` URL on one of the task's allowed domains |
| `click` | `selector` | Click the element |
| `fill` | `selector`, `text` | Set the value of an input or text area |
| `type` | `selector`, `text` | Type the text key by key |
| `press` | `key`, optional `selector` | Press a key, for example `Enter` |
| `select` | `selector`, `value` | Choose an option of a `<select>` |
| `check` | `selector` | Tick a checkbox or radio button |
| `wait_for` | `selector` or `text` | Wait until it appears |
| `wait` | `ms` | Wait 1 to 10000 ms |
| `assert_url` | `url` | Stop with an error unless the page URL starts with it (`https://`, allowed domain) |
| `assert_text` | `text` | Stop 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](https://developers.openwaf.dev/tasks/limits/#per-domain) does not allow the action yet, the approval stays `approved` with `not_before` and runs then.

## States

| Approval | Meaning |
|---|---|
| `pending` | Waiting for a person |
| `approved` | Approved; waiting for the runner or for `not_before` |
| `executed` | The steps ran to the end |
| `failed` | A step failed; `result.error` says which |
| `rejected` | A person rejected it |
| `expired` | Nobody 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.policy` | Meaning |
|---|---|
| `required` | Every action waits for a person. The only policy you can set. |
| `relaxed` | Proposals 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:

```http
X-OpenWAF-Signature: t=1761735600,v1=5d2f…
X-OpenWAF-Event: approval.requested
X-OpenWAF-Delivery: 0b9d5c3e-…
```

```json
{"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:

```js
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);
}
```
