{"slug": "statelock-deterministic-guardrails-for-browser-agents-enforced-at-cdp", "title": "Statelock: Deterministic guardrails for browser agents, enforced at CDP", "summary": "Statelock, an open-source project from Inferlink, released a Chrome DevTools Protocol (CDP) proxy that enforces deterministic policy checks on AI browser agents before each click, keystroke, upload or download and records evidence for every decision. The tool installs as the PyPI package `statelock-ai` (importing as `statelock`; the PyPI package named `statelock` is an unrelated project) and works with Playwright, browser-use, Stagehand, LangChain or any CDP client against headless Chromium. In its built-in finance demo, a policy comparing `bank_deposit_amount` with `erp_invoice_amount` blocked a \"Mark as Paid\" click because the deposit was $4,900.00 against a $5,000.00 invoice, with the page, checked values, verdict and a screenshot stored under `artifacts/sessions/<session id>/`.", "body_md": "Statelock adds policy checks and evidence to AI browser automations. It is a Chrome DevTools Protocol (CDP) proxy between your agent (Playwright, browser-use, Stagehand, LangChain or any CDP client) and a headless Chromium.\n\nBefore each click, keystroke, upload or download, Statelock captures the page and checks your policy, then releases or blocks the action. After a commit action it checks post-conditions. It records evidence for every decision.\n\nAn agent can then work in a real web portal inside clear limits: it may decline a manuscript but not send it to review, or mark an invoice as paid only when the bank deposit matches.\n\nInstall it (the package is `statelock-ai` and imports as `statelock`; the PyPI package named `statelock` is an unrelated project):\n\n```\npip install statelock-ai\npython -m playwright install chromium\n```\n\nWrite a policy and a key for your agent. This policy blocks \"Mark as Paid\" on the built-in finance demo when the bank deposit and the invoice differ:\n\n```\ncat > policy.yaml <<'EOF'\npolicies:\n  - agent_id: finance_agent\n    target_url_contains: /demo/finance\n    pre_conditions:\n      - assert_field_equal: {left: bank_deposit_amount, right: erp_invoice_amount}\nEOF\nstatelock keygen --agent-id finance_agent --append keys.yaml   # prints the agent's key; keys.yaml holds only its hash\n```\n\nStart Statelock with the demo pages on:\n\n```\nSTATELOCK_POLICY_FILE=policy.yaml STATELOCK_AUTH_KEYS_FILE=keys.yaml STATELOCK_ARTIFACT_DIR=artifacts \\\n  STATELOCK_DEMO=1 statelock --port 8010\n```\n\nIn another shell, run an agent that tries to mark a mismatched invoice as paid:\n\n```\nexport STATELOCK_URL=http://localhost:8010 STATELOCK_API_KEY=slk_...   # the key keygen printed\ncat > agent.py <<'EOF'\nimport asyncio\n\nfrom playwright.async_api import async_playwright\n\nimport statelock.client\n\nasync def main() -> None:\n    async with async_playwright() as p:\n        async with await statelock.client.connect_playwright(p) as governed, governed.guard():\n            await governed.page.goto(\"http://localhost:8010/demo/finance?scenario=mismatch\")\n            await governed.page.click(\"text=Mark as Paid\")\n\nasyncio.run(main())\nEOF\npython agent.py\n# StatelockPolicyViolationError: [pre_condition/assert_field_equal] Blocked action because\n# bank_deposit_amount ($4,900.00) did not equal erp_invoice_amount ($5,000.00) ...\n```\n\nThe click never reached the page. `artifacts/sessions/<session id>/` holds the evidence: the page, the values checked, the verdict and a screenshot.\n\nFor sync Playwright, browser-use, LangChain, CrewAI, Stagehand and JavaScript agents, see [Connecting an agent](https://github.com/inferlink/statelock/blob/main/README.md#connecting-an-agent).\n\nFrom a checkout of this repository, start the development proxy and run the two finance cases. The development stack supplies demo keys and enables the demo pages; no local Python installation or key generation is needed.\n\n```\ncp .env.example .env   # compose.yaml reads it (env_file); the defaults are fine\ndocker compose -f compose.yaml -f compose.dev.yaml up -d --build statelock\ndocker compose -f compose.yaml -f compose.dev.yaml --profile finance-agent run --rm finance-agent\ndocker compose -f compose.yaml -f compose.dev.yaml --profile finance-pass-agent run --rm finance-pass-agent\n```\n\nThe first agent is blocked because the deposit is $4,900 and the invoice is $5,000; the second completes when they match. The rule is in [policies/default.yaml](https://github.com/inferlink/statelock/blob/main/policies/default.yaml), under `finance_reconciliation_agent`: it compares `bank_deposit_amount` with `erp_invoice_amount` before the action and checks for \"Reconciliation complete\" afterward. See [Policies](#policies) to write your own.\n\nFor the test suite, base-proxy Docker setup and other demos, see [Development](#development), [compose.dev.yaml](https://github.com/inferlink/statelock/blob/main/compose.dev.yaml) and the [OJS walkthrough](https://github.com/inferlink/statelock/blob/main/examples/ojs/README.md).\n\n1. **Admission.** The agent connects with a single-use session URL, or to`ws://<host>/statelock` with`Authorization: Bearer <key>` ,`x-statelock-agent-id` (optional; it must match the key), and optionally`x-statelock-session-id` (a UUID). A missing, unknown, disabled or expired key, a key for another agent, or an agent without a policy is refused (`4401` ), as are invalid or reused session IDs (`4409` ).\n2. **Governed Chromium.** Each session gets its own Chromium, started with a minimal environment. CDP runs over a pipe (`--remote-debugging-pipe` ), so there is no DevTools port a page could reach. CDP is forwarded in both directions; only valid JSON is forwarded, exactly as Statelock evaluated it. Commands that would bypass governance are refused:\n  - wrapped commands (`Target.sendMessageToTarget` ) and`Debugger.setScriptSource` ;\n  - loading anything but an http(s) URL or `about:blank` (`javascript:` ,`file:` ,`data:` ,`blob:` ,`chrome:` ,`view-source:` );\n  - `Target.exposeDevToolsProtocol` ,`Network.replayXHR` and`Page.setDocumentContent` ;\n  - commands that reach Statelock's own isolated worlds;\n  - agent network interception (`page.route` , any`Fetch.*` ), agent-selected browser proxies and certificate-error overrides.\n3. wrapped commands (\n4. **Page guard.** Installed on every page and iframe before the agent's commands reach it. On governed pages (URLs your policies cover) it:\n  - cancels synthetic clicks, submits, drops and pastes (`element.click()` ,`dispatch_event` );\n  - clears files that code puts into file inputs (including Playwright's own `set_input_files` over remote CDP;`statelock.client.install()` routes it through Statelock, see[Files](https://github.com/inferlink/statelock/blob/main/README.md#files) );\n  - blocks page loads and requests started by the agent's own page code;\n  - blocks form submissions code makes (`form.submit()` , and`form.requestSubmit()` outside a real click, Enter or touch);\n  - also guards the page's iframes (srcdoc, about:blank and other origins), like the page.\n5. cancels synthetic clicks, submits, drops and pastes (\n6. **Governed input.** For each input command (mouse, key, text, touch, drag and drop, IME, gestures, and`DOM.setFileInputFiles` ) Statelock:\n  - captures state from the command's tab, in its own isolated world: URL, text, the element under the pointer or focused, fields, accessibility tree, screenshot;\n  - evaluates pre-conditions;\n  - forwards or blocks the command;\n  - for commit actions (mouse release, touch end, tap, drop, Enter release), holds the browser's response, evaluates post-conditions, then releases or blocks.\n7. **Attribution.** Code the agent sends (`Runtime.evaluate` ,`Runtime.callFunctionOn` , ...) is tagged, so every request can be attributed to`agent_code` ,`agent_navigation` ,`site` or`unknown` . Real input and the site's own code are never blocked by the guard, so multi-step tasks work normally.\n8. **Evidence.**  - One record per action, in sequence order: `artifacts/sessions/<id>/action-XXXX/context.json` plus screenshots.\n  - A request log per session: `network.jsonl` .\n  - Redacted: injected secrets, SSNs, card-like numbers that pass the Luhn check, and emails.\n  - Written to `STATELOCK_ARTIFACT_DIR` ; with Docker, this repo's`./artifacts` . Plain JSON: tamper evidence comes from[Statelock Enclave](https://github.com/inferlink/statelock/blob/main/README.md#statelock-enclave) .\n9. One record per action, in sequence order: \n10. **Violations.** The agent receives an in-band CDP error and the session closes (`4403` pre-condition,`4412` post-condition).`GET /violations/<session_id>` serves the details, and the client SDK raises`StatelockPolicyViolationError` .\n\nSee [COVERAGE.md](https://github.com/inferlink/statelock/blob/main/COVERAGE.md) for what is and is not analyzed.\n\n```\npolicies:\n  - id: finance-reconciliation                 # optional; names the policy in verdicts and evidence\n    agent_id: finance_reconciliation_agent     # required\n    target_url_contains: /demo/finance          # omit to apply to every page\n    allow_synthetic_events: false               # true disables the page guard for this policy\n    pre_conditions:                             # one rule per entry, optionally with a trigger\n      - require_page_text: {values: [Bank Deposit, ERP Invoice]}\n      - assert_field_equal: {left: bank_deposit_amount, right: erp_invoice_amount}\n      - prohibit_click_text: {values: [Delete, Void]}\n      - trigger: {click_text: [Mark as Paid]}   # only just before that button is pressed\n        assert_compare: {left: erp_invoice_amount, op: \"<=\", value: 10000}\n    post_conditions:                            # the same, checked after the action\n      - trigger: {click_text: [Mark as Paid]}   # or key: [Enter]\n        require_page_text: {values: [Reconciliation complete]}\n    cookie_access:                              # optional; default: the agent reads no cookies\n      names: [csrftoken]                        # exact names; or domains: [...]; or all: true\n```\n\n`target_url_contains` matches the page URL as `scheme://host[:port]/path`, without query, fragment or user info. A path-only scope matches as a substring. An absolute scope (`https://host/path`) needs that exact origin and a path equal to its path or below it.\n\nA `trigger` limits an entry to particular actions. It goes in the entry beside the rule, not inside the rule's options. On a pre-condition it runs only when an element whose text or aria-label contains a `click_text` value is activated (pressed, touched, tapped, dropped on, or Enter/Space while focused), or on the key down of a listed `key`: it checks the page just before the button acts. On a post-condition it runs after that action, on the page it led to. Without a trigger, pre-conditions run on every action on the policy's pages and post-conditions after every click or Enter. An unresolved click target counts as a match (fail closed). `restrict_uploads` and `restrict_downloads` take no trigger.\n\n`id` names the policy in verdicts, violations and the evidence (`policy_id`), so with several policies for one agent the record says which one blocked. Without it the id is `<agent_id>#<n>`: the agent's n-th policy across the loaded policy files, in load order (so reordering policies renames the defaults; set `id` where it matters). Ids are unique across all loaded policy files.\n\n`cookie_access` applies to the agent (the union of its policies). Cookies are not exported by default: with the site's session cookies, Playwright's `page.request` / `context.request` (without `install()`) or another HTTP client would act as the logged-in user outside Statelock. So Statelock answers cookie reads over CDP (`context.cookies()`, `storage_state()`) with only the cookies `cookie_access` allows. With no `cookie_access`, the read is declined (` cookie_export`), so those calls fail instead of bypassing Statelock, and the session continues. Either way the read is recorded, with cookie names, never values. Network events the agent receives are filtered too: `Cookie`, `Set-Cookie`, `Authorization`, `Proxy-Authorization`, `X-Api-Key`, `X-Auth-Token`, `X-Csrf-Token` and `X-Xsrf-Token` header values read `[REDACTED]`, cookie lists are empty, and raw header text is removed. `all: true` hands over the site's session cookies, which lets agent-side HTTP act as the user outside Statelock; use it knowingly. For HTTP, use [`request_access`](#agent-side-http-request_access): after `install()`, `page.request` goes through Statelock.\n\nA policy, keys or secrets file with a repeated key at any level (two `pre_conditions:`, two `agents:` sections) is refused when it loads.\n\nAn agent that needs HTTP (a site's JSON API, a file behind a link) can have Statelock make the request. Statelock runs it in the page, with the browser's session, so the agent needs no cookies:\n\n```\n    request_access:                             # optional; default: no requests\n      - url_pattern: \"^https://portal\\\\.example\\\\.com/api/\"   # regex matched from the start of the URL\n        methods: [GET, POST]                    # default [GET]\n        max_response_bytes: 5000000             # default 8 MiB, at most 11 MiB\nstatelock.client.install()                                   # then Playwright's own page.request works:\ninvoices = await (await page.request.get(\"https://portal.example.com/api/invoices\")).json()\n# or without install(): await statelock_fetch(page, url, method=\"POST\", data={...})\n```\n\n- **Patterns.**`url_pattern` must start with`^https://` ,`^http://` or`^https?://` , then a literal host (escape dots as`\\.` ), an optional port (`:8443` , or`:[0-9]+` for any) and`/` . It is matched from the start of the URL, and the URL must also begin with exactly that`scheme://host[:port]/` : no user info, and no port the pattern does not name. Wildcard hosts and`|` outside a group are rejected.\n- **URLs.** Statelock writes the URL as the browser reads it (lowercase scheme and host, no default port, no fragment) and checks, scopes and fetches that one string. A URL that Python and the browser could read differently is declined (`request_access` ): spaces, control or non-ASCII characters, backslashes, user info (`user@host` ),`.` /`..` path segments, IPv4 addresses not written as`a.b.c.d` , invalid ports and IPv6 zone ids.\n- **Where it runs.** Statelock makes the request with`fetch()` in an isolated world of the page, as the site's own code would, so only same-origin or CORS-allowed URLs work. The browser's cookies go with it; the agent never receives them.\n- **Recorded.** Each request is an action record: URL, method, header names, body size, status, and the response's size and SHA-256. It shows as`statelock_request` in the network log.\n- **Declined.** A request no rule allows is answered with an error (`request_access` ,`StatelockRequestError` ) and recorded. The session continues.\n- **Redirects.** They fail before the destination request is sent.`follow_redirects: true` is rejected during policy validation because the destination cannot be checked before the browser follows it.\n- **Secrets.** Header values may use`{{secret:name}}` (for example`Authorization: Bearer {{secret:api_token}}` ). The secret needs`password_fields_only: false` , and the request URL must be in its scope (see[Credentials](https://github.com/inferlink/statelock/blob/main/README.md#credentials) ). A placeholder that is not allowed ends the session (`secret_injection` ), and the value is removed from the response if the endpoint echoes it.\n- **Concurrent.** A request runs alongside the agent's other commands, at most`STATELOCK_MAX_CONCURRENT_REQUESTS` (4) at once per session. One more is declined (`request_concurrency` ,`StatelockRequestError` ) and recorded; the session continues.\n- **Not supported** through`page.request` :`multipart` ,`max_redirects` ,`max_retries` ,`ignore_https_errors` .\n\nRules read named fields. A field is either `data-statelock-key` / `data-statelock-value` markup on the page, or a policy `fields` entry, which works on sites you cannot change:\n\n```\n  - agent_id: portal_reconciliation_agent\n    target_url_contains: /demo/erp\n    fields:\n      - name: bank_deposit\n        url_contains: /demo/bank        # default: the policy's target_url_contains\n        allowed_origins: [\"http://statelock:8000\", \"http://localhost:8010\"]\n        selector: \"#deposit-amount\"     # CSS selector; or key: <data-statelock-key>\n        # attribute: title              # read an attribute instead of the text\n        # pattern: \"\\\\$([0-9,.]+)\"      # optional regex; group 1 is the value\n        remember: true                  # keep the latest value for this session\n      - name: erp_invoice\n        selector: \"#invoice-amount\"\n      - {name: invoice_id, from_url: true, pattern: \"/invoice/(\\\\d+)\"}   # from the page URL\n      - {name: open_dialogs, selector: \".modal\", visible: true, read: count}\n      - {name: note_length, selector: \"body\", frame: \"iframe#note-editor\", read: length}\n    pre_conditions:\n      - assert_field_equal: {left: remembered.bank_deposit, right: erp_invoice}\n      - assert_compare: {left: erp_invoice, op: \"<=\", value: 10000, on_fail: review}\n      - restrict_uploads: {extensions: [.pdf], max_bytes: 5000000, max_files: 1}\n```\n\nA field reads one of: `selector` (the first match's text, or `attribute`), `key` (`data-statelock-key` markup), or `from_url: true` (the page URL; use `pattern`). A policy field is read only as the policy defines it, never from markup of the same name. `data-statelock-value` applies only to `data-statelock-key` fields; a selector field reads what the page shows. `url_contains` matches like `target_url_contains`. Selector fields also take:\n\n- `frame` : a same-origin iframe to look in (a rich-text editor's body, say);\n- `visible: true` : only elements that are shown: rendered (not`display: none` ,`visibility: hidden` ,`opacity: 0` or hidden content, itself or an ancestor), with a box larger than a pixel that is not left of or above the page (below the fold counts);\n- `read: count` (how many elements match,`0` for none) or`read: length` (characters of the value). A password field is never read;`read: length` is the only thing Statelock takes from one.\n\n`remembered.<name>` is the latest value of a `remember: true` field seen anywhere in the session (any tab). A remembered field must name trusted origins with `allowed_origins`, or use an absolute `url_contains` scope; a path alone could be copied on another site. Statelock captures it at each governed action, when a matching page finishes loading (and again 0.5 s later), and before the agent navigates away (goto, reload, back/forward). Each action record stores the remembered values with their URL, source and time, and a rule that needs a missing value blocks.\n\nBuilt-in rules:\n\n| Rule | Where it can be used | What it checks | \n|---|---|---|\n| `require_page_text` | pre and post | The page text contains each listed value. Text is compared after Unicode NFKC and case folding, ignoring invisible characters (soft hyphen, zero-width space) and differences in whitespace. | \n| `prohibit_page_text` | pre and post | The page text contains none of the listed values (an error message, a login form after logging in), compared like `require_page_text` and also ignoring spaces altogether (\"MarkasPaid\" matches \"Mark as Paid\"). Blocks if the page text was cut short or parts of the page could not be read. | \n| `assert_field_equal` | pre and post | Two fields (or remembered values) hold the same value, or a field equals a fixed `value` . Numbers compare as numbers; text compares after Unicode NFKC and case folding, ignoring only whitespace and punctuation (punctuation between digits and a leading minus are kept). Amounts in different currencies are never equal: ISO 4217 codes (upper case, next to the amount) and symbols are compared (\"€5\" equals \"5 EUR\"), and \"$\" or \"¥\" alone is not taken as any one code (\"$5\" does not equal \"USD 5\"; \"US$5\" does). | \n| `assert_compare` | pre and post | A numeric comparison ( `<` ,`<=` ,`>` ,`>=` ,`==` ,`!=` ) of a field with another field (`right` ) or a constant (`value` ). | \n| `prohibit_click_text` | pre only | The clicked or touched element, or the focused element for Enter/Space, does not contain a listed text. Text is compared like `require_page_text` . | \n| `restrict_downloads` | pre only | Downloads the page starts: allowed `extensions` ,`name_pattern` , source`url_pattern` and`max_downloads` (per session) when the download begins;`max_bytes` when it completes. A blocked download is cancelled or deleted and ends the session. | \n| `restrict_uploads` | pre only | Governed uploads: allowed `extensions` ,`max_bytes` per file,`max_files` per upload,`name_pattern` . Other actions pass. | \n| `visual_assert` | pre and post | A vision-language model judges the screenshot and can read values off it; `cross_check` compares those with page fields. Fails closed (see below). | \n\n- **Text rules.**`require_page_text` ,`prohibit_page_text` and`prohibit_click_text` need at least one value with visible characters.\n- **Numbers.**`$5,000.00` ,`-$5` ,`$-5` ,`−5` (Unicode minus) and`(1,234.00)` (negative) parse. Ambiguous formats such as`5.000,00` ,`5-` or`12.50)` do not, and the rule blocks. Amounts are compared exactly, at any length.\n- **Loading.** Entries with zero or several rules, unknown rules, and`remembered.<name>` references to a name that is not a`remember: true` field of the agent are rejected when the policy file loads. Other field names cannot be checked then (a page can supply them with`data-statelock-key` ); a missing field blocks at runtime. A rule that raises or does not answer within its time limit (30 s by default) blocks the action.\n\nWhen a check needs more than page fields (a lookup in the system that holds the decision, a call to another service), write it as a rule and name it in the policy like a built-in one:\n\n``` python\nfrom typing import ClassVar\nfrom statelock.policy.rules import Rule, RuleContext, RuleFailure, register_rule\n\n@register_rule\nclass ApprovedInvoice(Rule):\n    rule_name: ClassVar[str] = \"approved_invoice\"\n    phases: ClassVar[frozenset[str]] = frozenset({\"pre\"})\n    check_timeout: ClassVar[float | None] = 10.0\n    invoice_field: str\n\n    async def check(self, ctx: RuleContext) -> RuleFailure | None:\n        invoice = ctx.field(self.invoice_field)\n        if not await is_approved(invoice):   # your lookup; asyncio.to_thread for blocking I/O\n            return RuleFailure(f\"Blocked: invoice {invoice} is not approved\", ctx.field_evidence(self.invoice_field))\n        return None\n- trigger: {click_text: [Mark as Paid]}\n        approved_invoice: {invoice_field: invoice_id}\n```\n\nStatelock loads rule modules at startup from the `statelock.rules` entry point group (a package that declares `[project.entry-points.\"statelock.rules\"]`) and from `STATELOCK_RULE_MODULES` (module names or `.py` paths). Rules run inside the proxy, installed by whoever runs it; an agent cannot add one. A rule name can be registered once. `examples/ojs/ojs_rules.py` is a worked example: it re-checks the editors' decision before a paper is declined.\n\n`visual_assert` asks a vision-language model (VLM) about the screenshot Statelock captured for the action (pre-conditions) or after it (post-conditions):\n\n```\n    pre_conditions:\n      - visual_assert:\n          check_name: amounts_match\n          instruction: The bank deposit and the ERP invoice show the same amount.\n          extract: {bank_deposit: string}               # optional: values the model reads off the page\n          cross_check:                                  # optional: its reading must equal a page field\n            - {extracted: bank_deposit, field: bank_deposit_amount}\n          # when: activation                            # default: presses, taps, drops, Enter/Space; or: all\n```\n\n- \n**Model.** Any vision model litellm supports (`pip install \"statelock-ai[vlm]\"` ), hosted or self-hosted:Setup `STATELOCK_PERCEPTION_MODEL`` STATELOCK_PERCEPTION_API_BASE`Hosted, e.g. the Gemini your agent already uses `gemini/gemini-3.5-flash` (key in`STATELOCK_PERCEPTION_API_KEY` )none Ollama on the same machine `ollama_chat/qwen2.5vl:7b`` http://localhost:11434`vLLM on a GPU server in your network `hosted_vllm/Qwen/Qwen2.5-VL-7B-Instruct`` http://gpu:8000/v1`\n- \n**Tested models** (the finance screenshots in`tests/fixtures/perception` : one where the amounts match, one where they differ). Gemini 3.5 Flash judged both correctly and read all four amounts, in 3–5 s per check. Qwen2.5-VL 3B through Ollama, on the CPU in Docker on a Mac, read all four amounts correctly, but called the mismatch a match, in 30–40 s per check. A model that small is not reliable for judgments: self-hosted, use a strong vision model on a GPU, and run`tests/test_perception_model.py` against it before relying on it.\n- \n**What a hosted model receives.** A screenshot of the page at every checked action, with the instruction. That can include customer data, and a secret typed into a field that is not a password field. Use a hosted model only where sending those screenshots to the provider is acceptable.\n- \n**Local only.**`STATELOCK_PERCEPTION_LOCAL_ONLY=true` (for air-gapped and regulated deployments) makes Statelock refuse to start unless the model is self-hosted and on this machine or a private network. The model needs a self-hosted provider prefix (`ollama` ,`ollama_chat` ,`hosted_vllm` ,`openai` for an OpenAI-compatible server,`lm_studio` ), because a hosted provider can route to its own cloud.`STATELOCK_PERCEPTION_API_BASE` must be local: loopback, RFC 1918 and Docker networks, link-local, unique-local IPv6, or an overlay network such as Tailscale (100.64.0.0/10). A host name must resolve, and every address it resolves to must be local. This is checked once, at startup. It also stops litellm from downloading its model price list.\n- \n**Strict output.** The model must answer`{\"passed\", \"reason\", \"extracted\"}` matching a strict JSON schema (sent as`response_format` , temperature 0). Output is validated with no type coercion and no extra keys.\n- \n**Fails closed.** No model configured, no screenshot, a timeout (`STATELOCK_PERCEPTION_TIMEOUT` ), a model error, or invalid output after`STATELOCK_PERCEPTION_RETRIES` more attempts: the rule fails with the reason. A failure blocks, or pauses for review with`on_fail: review` .\n- \n**Cross-checks.** A model can misread or be fooled by the page.`cross_check` compares each value it read with a DOM field (numbers as numbers), so a visual check can back up, not replace, a deterministic one.\n- \n**Cost.** One model call per checked action: pre-conditions check activations only unless`when: all` . The same screenshot and question are answered from a cache. The agent's action waits for the model, so set its action timeouts above`STATELOCK_PERCEPTION_TIMEOUT` .\n- \n**Evidence.** The action record has the model's verdict, what it read, the model name, attempts and latency (`evidence.perception` ,`evidence.cross_check` ).\n- \n**Try a model:**`statelock perception-check screenshot.jpg -i \"The amounts match.\" -x bank_deposit=string` .`tests/test_perception_model.py` runs the finance fixtures (`tests/fixtures/perception` ) against the configured model and prints its answers; it checks that they have the expected form and are correct (`PERCEPTION_CHECK_ANSWERS=false` checks the form only).\n- \n**Try a local model in Docker:** the`vlm` profile in`compose.dev.yaml` runs Ollama with`DEMO_VLM_MODEL` (default`qwen2.5vl:3b` ) and runs that test against it (see[Development](#development) ). It prints the answers and checks only their form, because a 3B model can misjudge; set`DEMO_VLM_CHECK_ANSWERS=true` in`.env` to also require correct answers.\n\nAny pre-condition rule takes `on_fail`: `block` (the default) ends the session; `review` pauses the action until a reviewer approves or denies it.\n\n- Only activations wait: a mouse press, a touch start, a tap, a drop, a file upload, and Enter, Space or a Ctrl/Alt/Meta shortcut. The approval covers the matching release, so one click is one review. Other actions (moves, scrolls, plain typing) continue; the failure is recorded as `review_deferred` .\n- A failing `on_fail: block` rule still blocks at once, without a review.\n- On approval, Statelock captures the page again. If a blocking rule now fails, or a reviewed rule fails differently (for example, the amount changed), the action is blocked.\n- A denial, or no decision within `STATELOCK_REVIEW_TIMEOUT` (300 s), blocks the action and ends the session. The violation carries the review.\n- `on_fail: review` is refused in post-conditions (the action already ran) and on`restrict_downloads` (a download cannot wait). A download whose page fails a review rule is blocked.\n- The action record stores a `review` block (id, status, reviewer, comment, times, failed rules) and`review-screenshot.jpg` , the page the reviewer judged.\n- Agents: Playwright's default 30 s action timeout ends before a person answers. Raise it (`context.set_default_timeout(...)` ) above the review timeout.\n\nReviewers use `http://<proxy>/review`, or the API with a reviewer key (` Authorization: Bearer`):\n\n| Endpoint | Purpose | \n|---|---|\n| `GET /reviews` | Pending reviews of the reviewer's tenant ( `?status=approved\\|denied\\|expired\\|cancelled\\|all` ). | \n| `GET /reviews/<id>` | One review: the paused action, page, fields, remembered values, failed rules. | \n| `GET /reviews/<id>/screenshot` | The page when the action was paused (JPEG). | \n| `POST /reviews/<id>/decision` | `{\"decision\": \"approve\" \\| \"deny\", \"comment\": \"...\"}` . 409 if already decided or expired. | \n\nEvents `review_requested` and `review_decided` let plugins notify reviewers (for example in chat).\n\nPolicy Studio (experimental) helps draft a policy from tasks done by hand. Load Chrome Recorder JSON exports of runs the policy must allow and runs it must block (with the step and the reason), write the checks each step needs in your own words, discuss them with a model of your choice (through LiteLLM), and download a draft that passes Statelock's schema check. The raw recordings stay in the browser; you review each request before it is sent. Test a draft like any policy: start Statelock with it and run your agent on the same cases. It runs from a clone of this repository, natively or with Docker; see [tools/policy-studio/README.md](https://github.com/inferlink/statelock/blob/main/tools/policy-studio/README.md), and [examples/ojs/POLICY_STUDIO.md](https://github.com/inferlink/statelock/blob/main/examples/ojs/POLICY_STUDIO.md) for a walkthrough.\n\nAgents authenticate with API keys (`STATELOCK_AUTH_MODE=api_key`, the default). The keys file stores only SHA-256 hashes. `keygen --append` adds each entry under its section (and creates the file if it is missing); the key is printed on stderr:\n\n```\nstatelock keygen --agent-id finance_reconciliation_agent --tenant acme --append keys.yaml\nstatelock keygen --reviewer alice --tenant acme --append keys.yaml      # a reviewer key (reviewers: section)\nstatelock keygen --auditor audit-team --tenant acme --append keys.yaml  # an auditor key (auditors: section)\nstatelock hash-key < key.txt                                            # hash an existing key\n```\n\nWithout `--append`, `keygen` prints a complete keys file on stdout. Do not add that to an existing file with `>>`: a second `agents:` section makes the file invalid.\n\n```\nagents:\n  - agent_id: finance_reconciliation_agent\n    tenant: acme                    # recorded with every action and violation\n    key_sha256: 3b1f...\n    expires: 2027-01-01T00:00:00Z   # optional\n    disabled: false                 # optional; several entries per agent allow key rotation\nreviewers:\n  - reviewer_id: alice\n    tenant: acme                    # reviews only this tenant's sessions\n    key_sha256: 9c0d...\nauditors:                           # for plugins that serve evidence\n  - auditor_id: audit-team\n    tenant: acme                    # reads only this tenant's evidence\n    key_sha256: 51ab...\n```\n\nEach key has one role. Agent keys cannot use the review API, reviewer keys cannot open sessions, and auditor keys only read evidence through plugin routes.\n\n- The proxy needs `STATELOCK_AUTH_KEYS_FILE` and fails at startup without it.`compose.yaml` mounts`./keys.yaml` (or`STATELOCK_KEYS_FILE` ), which must exist.\n- The agent sends the key as `Authorization: Bearer <key>` . The SDK takes`api_key=` or the`STATELOCK_API_KEY` environment variable.\n- `GET /violations/<session_id>` needs the same key and returns only that agent's sessions (401 without a valid key, 404 for other agents' sessions).\n- Upload commands run on the agent's authenticated connection.\n- `docker/dev-keys.yaml` holds public development keys (`slk_dev_<agent_id>` ,`slk_dev_dev_reviewer` for the reviewer,`slk_dev_dev_auditor` for the auditor) for the demo agents in`compose.dev.yaml` . Never deploy it.\n- `STATELOCK_AUTH_MODE=none` turns authentication off (agent IDs are self-asserted; anyone may review, named by`x-statelock-reviewer-id` ) and logs a warning. Use it only for local development.\n- Plugins can protect their own routes with the FastAPI dependencies `statelock.app.authenticated_identity` (agents),`statelock.app.authenticated_reviewer` (reviewers) and`statelock.app.authenticated_auditor` (auditors).\n\nFor async Playwright, `connect_playwright()` is the whole integration:\n\n``` python\nimport statelock.client\n\nstatelock.client.install()                                        # Playwright's file APIs and page.request go through Statelock\nasync with await statelock.client.connect_playwright(playwright) as governed:\n    async with governed.guard():\n        page = governed.page                                      # continue with existing Playwright code\n```\n\n`connect_playwright()` reads `STATELOCK_URL` and `STATELOCK_API_KEY`, creates a single-use session URL, connects Chromium over CDP, and returns the browser plus the session metadata; `async with` closes the connection at the end. Wrap the action section in `async with governed.guard():` so a blocked action raises `StatelockPolicyViolationError` with the rule and reason instead of looking like a closed browser:\n\n``` python\nfrom statelock.client import StatelockPolicyViolationError\n\ntry:\n    async with governed.guard():\n        await page.click(\"text=Mark as Paid\")\nexcept StatelockPolicyViolationError as violation:\n    print(violation.rule, violation.reason)\n```\n\nThe guard looks the violation up with the key the session was created with (else `STATELOCK_API_KEY`). If Statelock refuses that key or cannot be reached, it raises `StatelockClientError` (with `status`) instead of reporting no violation.\n\nThe sync API has the same names ending in `_sync` / `Sync` (`install_sync`, `connect_playwright_sync`, `statelock_guard_sync`, `expect_download_sync`, ...). `statelock_fetch` is async only; `create_session_url` and the saved-session functions are plain blocking calls that both APIs use:\n\n``` python\nimport statelock.client\n\nstatelock.client.install_sync()\nwith statelock.client.connect_playwright_sync(playwright) as governed, governed.guard():\n    page = governed.page\n```\n\nUnder the hood, Statelock uses a CDP browser URL. Any framework that takes such a URL (Playwright, Puppeteer, browser-use, Stagehand, LangChain or CrewAI browser tools) can use one.\n\nAfter an action that navigates (a click on a submit button, Enter in a form), wait for its result (a URL, an element) rather than for `networkidle`. Statelock returns the action once it has checked the policy's post-conditions, and by then the next page may already be idle, so a wait that only listens for a new `networkidle` event can miss it.\n\nNaming: a *session* is one governed browser session. `SessionUrl` is the single-use URL that opens one, `PlaywrightSession` is that session with its connected Playwright browser, `statelock_session(page)` returns the `{session_id, agent_id}` of the page's session (None for a browser not behind Statelock), and a *saved session* is a stored browser login that a new session can restore.\n\nThe agent's key asks Statelock for a single-use URL:\n\n```\nexport STATELOCK_URL=http://localhost:8010 STATELOCK_API_KEY=<the agent key>\nstatelock session-url        # prints http://localhost:8010/sessions/slt_... (single use, expires in 300 s)\npython\nfrom statelock.client import create_session_url\n\nsession = create_session_url()                                   # STATELOCK_URL, STATELOCK_API_KEY\nbrowser = await playwright.chromium.connect_over_cdp(session.cdp_url)\nasync with session.guard():                                      # session.violation() after the fact\n    ...\n# Stagehand: StagehandConfig(env=\"LOCAL\", local_browser_launch_options={\"cdp_url\": session.cdp_url})\n```\n\n- **Two forms:**`cdp_url` is an http URL; frameworks read the WebSocket address from its`/json/version` .`ws_url` is the WebSocket itself.\n- **The token:** it identifies the agent and the session. It is used up when the browser connects and expires after`STATELOCK_SESSION_URL_TTL` .\n- **Storage and logs:** Statelock stores only the token's hash and redacts tokens from its logs.\n- **Errors:** a request Statelock refuses raises`StatelockClientError` , with the HTTP`status` .\n- **Headers instead:** agents that can send headers may connect to`ws://.../statelock` with`Authorization: Bearer <key>` and the`x-statelock-agent-id` /`x-statelock-session-id` headers (`connect_over_cdp(url, headers=...)` ).\n\nAn agent can reuse a site login without holding the cookies. A session URL names a saved session: Statelock puts its cookies and localStorage into the browser before the agent connects, and, with `save_session`, saves them again when the session ends cleanly. A session that ends on a violation or an error is not saved, so the state after a blocked action is never kept.\n\n```\nsession = create_session_url(saved_session=\"bank-login\", save_session=True)   # first run: logs in, saved at the end\nsession = create_session_url(saved_session=\"bank-login\")                      # later runs: already logged in\nstatelock session-url --saved-session bank-login --save\nstatelock saved-sessions list            # or: statelock saved-sessions delete bank-login\n```\n\n- **Off until configured.** Set`STATELOCK_SAVED_SESSIONS_KEY_FILE` to a path outside the saved-session directory, such as a mounted secret. Statelock creates the 32-byte key there (mode 0600) if the file does not exist. Back it up: without it the saved sessions cannot be read. Without the setting, a request that names a saved session is refused (422).\n- **Encryption.** Each file is encrypted with AES-256-GCM (`cryptography` ). The tenant, agent and name are bound to the file, so a file copied to another agent's name does not decrypt.\n- **Scope.** Saved sessions belong to one tenant and agent. Names are 1-80 characters: letters, digits,`.` ,`_` and`-` . They are stored under`STATELOCK_SAVED_SESSIONS_DIR` (default:`STATELOCK_ARTIFACT_DIR/saved-sessions` ).\n- **What is kept.** Cookies (HttpOnly included) and the localStorage of each open page's origin. localStorage is restored through a hidden page at the origin that Statelock answers itself, so nothing reaches the site. Not kept: sessionStorage (per tab, as after a browser restart), IndexedDB, and storage of cross-origin iframes.\n- **API.**`GET /saved-sessions` lists the calling agent's saved sessions and`DELETE /saved-sessions/<name>` deletes one. The client has`list_saved_sessions()` and`delete_saved_session(name)` .\n\nThe agent never needs the password. It types a placeholder, and Statelock types the secret:\n\n```\nawait page.fill(\"#password\", \"{{secret:bank_password}}\")   # the site receives the real password\n# STATELOCK_SECRETS_FILE\nsecrets:\n  - name: bank_password\n    agents: [finance_reconciliation_agent]       # who may use it\n    url_contains: https://bank.example.com/login # the origin, then this path or one below it\n    value_env: BANK_PASSWORD                     # or value_file: /run/secrets/bank_password\n    # password_fields_only: true                 # default: only into password fields\n```\n\n- **Where it is typed.** Statelock replaces the placeholder in typed text (`Input.insertText` , which Playwright's`fill` sends, and`Input.imeSetComposition` ) after the action passes its pre-conditions. It does so only for the listed agents, on a page whose URL has exactly the origin in`url_contains` (`http(s)://host[:port]` ) and a path equal to its path or below it (`/login` covers`/login/2fa` , not`/forum/login` or`/loginfoo` ), and (by default) only into a password field. Anywhere else, the placeholder ends the session (`secret_injection` ).\n- **What the agent sees.** The placeholder. Statelock replaces the value with`[SECRET]` in every answer the browser sends the agent and in every`request_access` response. Reading the field back returns`[SECRET]` .\n- **Evidence.** The typed text is recorded as`[SECRET]` , with the secret's name (`statelock_secrets` ). The value is removed from action records and the network log.\n- **Values.** They are read from the proxy's environment or files when it starts, and a missing value fails at startup. The agent's own environment never holds them.\n- **Limits.** Typing the placeholder key by key (`keyboard.type` ) sends it literally. Page code the agent runs could read the field and transform the value before returning it; no filter can recognise that.\n- **With a saved session** , the login usually happens once: later sessions start logged in.\n\nCookies are not exported either (see `cookie_access` in [Policies](#policies)).\n\nWhat no SDK can stop: plain HTTP from the agent's own process (for example `requests` with a password it holds). Use credential injection so the agent holds no password, and deploy the agent so it can reach only Statelock.\n\n`statelock.client.install()` (async) or `statelock.client.install_sync()` (sync), once at startup, routes Playwright's own file APIs through Statelock:\n\n- **Uploads:**`set_input_files` (on a page, locator or element handle) and`FileChooser.set_files` upload the files through Statelock. The upload is governed, and the file's name, size and SHA-256 are recorded.\n- **Downloads:**`page.expect_download()` waits for the download Statelock checked. A download is checked when it begins (the page's policies, including`restrict_downloads` ) and when it completes (size, SHA-256). Its value works like Playwright's Download (`save_as` ,`path` ,`suggested_filename` ,`url` ). Its default timeout is the page's`set_default_timeout` , as in Playwright.\n- **Other browsers are untouched:** each browser is asked once whether it is a Statelock session (`Statelock.session` ).\n- **Without `install()`:** the session object has the same two methods:` governed.set_input_files(target, files, page=None, timeout=None)` and`governed.expect_download(predicate=None, page=None, timeout=None)` (`page` defaults to`governed.page` ); also`statelock.client.set_input_files(page, target, files)` and`statelock.client.expect_download(page, predicate=None, timeout=None)` .`timeout` is in milliseconds, as in Playwright: by default the page's or context's`set_default_timeout` (set after`install()` ), else 30000; 0 waits without a limit. A blocked download raises`StatelockPolicyViolationError` with the rule Statelock reports; a guard adds the recorded violation. A`buffer` must be bytes-like or str; anything else raises`TypeError` . A path's MIME type comes from its extension (the same table as the JS SDK).\n\nLimits (see the [roadmap](https://github.com/inferlink/statelock/blob/main/ROADMAP.md)):\n\n- `page.on(\"download\")` listeners are not covered, and`install_sync()` does not cover sync`page.request` yet.\n- File inputs inside iframes are not supported yet (main frame only).\n- Upload rules (`restrict_uploads` ) check name, extension, size and count, not file contents.\n- File contents are not stored with the evidence. Only name, size and SHA-256 are recorded, and uploaded files are deleted when the session ends.\n\n**Script clicks.** Frameworks that click with page scripts (`element.click()`, as Stagehand does) work without changes. Statelock scrolls the element into view and replays the click as real mouse input at the element, checked and recorded like any click. It is replayed only when the element is visible and not covered, in the top frame; otherwise the click is blocked. Synthetic events from the site's own code, and other synthetic events (submit, drop, paste), are still blocked.\n\n**browser-use.** browser-use takes a CDP URL through `BrowserSession` and drives the browser over raw CDP, so every click and key press goes through Statelock (`pip install \"statelock-ai[browser-use]\"`, tested with browser-use 0.13, Python 3.11+):\n\n``` python\nfrom browser_use import Agent\nfrom statelock.integrations.browser_use import create_browser_use_session\n\ngoverned = await create_browser_use_session()   # STATELOCK_URL, STATELOCK_API_KEY\nagent = Agent(task=\"...\", browser_session=governed.browser, llm=...)\nawait agent.run()\ngoverned.raise_if_violation()             # StatelockPolicyViolationError (rule, reason)\n```\n\nbrowser-use reports a blocked action to its model as a failed step, not as an exception, so the run continues; Statelock has already ended the session, so nothing else gets through. `governed.violation()` / `raise_if_violation()` give the rule and reason afterwards. A lookup Statelock refuses (a wrong key) raises `StatelockClientError`, not \"no violation\". To build the browser-use object yourself, pass `create_session_url().cdp_url` to `BrowserSession(cdp_url=...)`. browser-use sends anonymous telemetry by default: set `ANONYMIZED_TELEMETRY=false` to turn it off.\n\n**LangChain and CrewAI.** Give LangChain's Playwright toolkit a browser from a session URL, and wrap its tools with `governed_playwright_tools` (`pip install \"statelock-ai[langchain]\"`):\n\n``` python\nfrom statelock.client import create_session_url\nfrom statelock.integrations.langchain import crewai_tools, governed_playwright_tools\n\nsession = create_session_url()\nbrowser = await playwright.chromium.connect_over_cdp(session.cdp_url)\ntools = governed_playwright_tools(browser, session)\n# CrewAI: use sync Playwright, then tools = crewai_tools(governed_playwright_tools(browser, session))\n```\n\nEvery click and keystroke is governed without the wrapper. What the wrapper adds:\n\n- **The agent learns why it stopped.** Without it, a blocked action looks like \"browser closed\". With it, when a tool fails or the browser disconnects, the wrapper asks Statelock, and a violation raises`StatelockPolicyViolationError` (rule, reason), which stops the agent run. Successful calls cost no lookup.\n- **Timeout.** It raises the click tool's 1 s timeout to 10 s, because a governed click waits for Statelock's checks.\n- **Selector bug.** It turns off the tool's`visible_only` suffix. Current Playwright never matches it, so every click would time out.\n\nThe wrapper returns copies; the tools passed in are not modified. `statelock_tools(tools, session)` wraps other LangChain tools that drive the governed browser. CrewAI's `StagehandTool` only runs on Browserbase's cloud browsers, so it cannot use a Statelock session; use the LangChain toolkit through CrewAI instead.\n\n**Stagehand.** `examples/ojs/` is a Stagehand agent on a mock journal: three Statelock lines in `open_session()` and `run_governed()`, and a password placeholder instead of the password (` pip install \"statelock-ai[stagehand]\"`).\n\n**JavaScript and TypeScript.** `@statelock/client` (`js/`) is not yet on npm; build it from `js/` (`npm ci` runs the build). It has:\n\n- `createSessionUrl()` ,`session.guard()` ,`session.violation()` and`secret()` ;\n- `statelockFetch()` (governed HTTP) and`listSavedSessions()` /`deleteSavedSession()` ;\n- `connectPlaywright()` (with`setInputFiles` /`expectDownload` ) and`install(browser)` for Playwright's file APIs;\n- CDP helpers for other frameworks.\n\n`examples/ojs-ts/` is the OJS agent in TypeScript on Stagehand v3. For other JavaScript frameworks, see the [JS SDK README](https://github.com/inferlink/statelock/blob/main/js/README.md#frameworks).\n\nAll settings are `STATELOCK_*` environment variables (`statelock/settings.py`). With Docker, put them in `.env`: Compose passes it to the proxy container (`.env.example` lists the common ones). The compose files set the policy, artifact and keys paths, and `compose.dev.yaml` also sets the demo, debug, secrets, saved-session, extra policy and rule module settings; those values override `.env`.\n\n| Variable | Default | Purpose | \n|---|---|---|\n| `STATELOCK_POLICY_FILE` | `/app/policies/default.yaml` | Policy bundle. A missing file fails at startup. | \n| `STATELOCK_EXTRA_POLICY_FILES` | none | More policy files, comma-separated, loaded with `STATELOCK_POLICY_FILE` as one bundle (ids unique across them). The dev stack adds`examples/ojs/ojs_policy.yaml` . | \n| `STATELOCK_RULE_MODULES` | none | Custom rule modules, comma-separated: module names or `.py` paths, loaded before the policy files (see Custom rules). | \n| `STATELOCK_ARTIFACT_DIR` | `/app/artifacts` | Local artifact root. | \n| `STATELOCK_PLUGINS` | `auto` | `auto` ,`none` , or a comma-separated list of plugin names. | \n| `STATELOCK_DEBUG` | `false` | Also writes the latest action record to `artifacts/latest.json` . | \n| `STATELOCK_DEMO` | `false` | Enables the demo pages `/demo/finance` ,`/demo/bank` and`/demo/erp` . | \n| `STATELOCK_LOG_LEVEL` | `INFO` | Python logging level, set on the root logger (all libraries). Empty leaves logging to the host. `DEBUG` also turns on`statelock.cdp` : one line per CDP message (method and ids, never page data), to see where a stalled page load stopped. | \n| `STATELOCK_AUTH_MODE` | `api_key` | `api_key` or`none` (development only). | \n| `STATELOCK_AUTH_KEYS_FILE` | none | Keys file (hashes). Required when `STATELOCK_AUTH_MODE=api_key` . | \n| `STATELOCK_SESSION_URL_TTL` | 300 | Seconds a session URL ( `POST /sessions` ,`statelock session-url` ) stays usable (at most 3600). | \n| `STATELOCK_SECRETS_FILE` | none | Secrets agents type as `{{secret:name}}` (credential injection). Values come from`value_env` or`value_file` ; a missing one fails at startup. | \n| `STATELOCK_SAVED_SESSIONS_KEY_FILE` | none (saved sessions off) | AES-256-GCM key for saved browser sessions, outside the saved-session directory. Created if missing. | \n| `STATELOCK_SAVED_SESSIONS_DIR` | `STATELOCK_ARTIFACT_DIR/saved-sessions` | Where saved browser sessions are stored. | \n| `STATELOCK_UPLOAD_DIR` | system temp | Base folder for per-session upload folders (deleted when the session ends). It must be readable by Chromium. | \n| `STATELOCK_UPLOAD_MAX_FILE_BYTES` ,`STATELOCK_UPLOAD_MAX_SESSION_BYTES` | 100 MiB, 500 MiB | Upload limits. | \n| `STATELOCK_DOWNLOAD_DIR` | system temp | Base folder for per-session download folders (deleted when the session ends). | \n| `STATELOCK_DOWNLOAD_MAX_FILE_BYTES` ,`STATELOCK_DOWNLOAD_MAX_SESSION_BYTES` | 100 MiB, 500 MiB | Download limits; a larger download is cancelled ( `download_limit` ). | \n| `STATELOCK_PERCEPTION_MODEL` | none (visual checks fail closed) | litellm model for `visual_assert` , e.g.`gemini/gemini-3.5-flash` ,`ollama_chat/qwen2.5vl:7b` or`hosted_vllm/Qwen/Qwen2.5-VL-7B-Instruct` . Needs`statelock-ai[vlm]` . | \n| `STATELOCK_PERCEPTION_API_BASE` ,`STATELOCK_PERCEPTION_API_KEY` | none | The model server's URL and key (a local vLLM or Ollama server needs only the URL). | \n| `STATELOCK_PERCEPTION_TIMEOUT` ,`STATELOCK_PERCEPTION_RETRIES` | 20, 1 | Seconds per model call; extra attempts after invalid output or an error. | \n| `STATELOCK_PERCEPTION_LOCAL_ONLY` | `false` | Refuse to start unless the model server ( `..._API_BASE` ) is on this machine or a private network. | \n| `STATELOCK_REVIEW_TIMEOUT` | 300 | Seconds a paused action waits for a reviewer before it is blocked. | \n| `STATELOCK_REVIEW_HISTORY_SIZE` | 1000 | Decided reviews kept in memory for the review API. | \n| `STATELOCK_VIOLATION_REGISTRY_SIZE` | 1000 | Violations kept in memory for `GET /violations/<session_id>` . | \n| `STATELOCK_MAX_CONCURRENT_REQUESTS` | 4 | `Statelock.fetch` requests (`request_access` ) running at once in one session. Another one is declined (`request_concurrency` ) and recorded; the session continues. | \n| `STATELOCK_REPLAY_SCRIPT_CLICKS` | `true` | Replay a plain `element.click()` from agent code as a governed real click.`false` : such clicks are violations. | \n| `STATELOCK_AGENT_SCRIPT_GRACE` | 0.5 | Seconds after agent code returns that a click it started still counts as the agent's (and is replayed). | \n| `STATELOCK_REPLAY_WAIT` | 10 | Seconds the agent's next command waits, at most, for a replayed click to finish. | \n| `STATELOCK_CHROMIUM_SANDBOX` | `false` (`1` in`compose.yaml` ) | Chromium sandbox. It needs a non-root user and a seccomp profile that allows user namespaces; `compose.yaml` provides both (see[docker/README-sandbox.md](https://github.com/inferlink/statelock/blob/main/docker/README-sandbox.md) ). If the sandbox cannot start, sessions are refused. | \n| `STATELOCK_CHROMIUM_START_TIMEOUT` | 30 | Seconds Chromium has to start and answer on its CDP pipe; otherwise the session is refused. | \n| `STATELOCK_CAPTURE_TIMEOUT` | 2.5 | Seconds a state capture may take before the action is blocked. | \n| `STATELOCK_POST_CAPTURE_SETTLE` | 0.2 | Seconds to wait after a commit action before capturing the page for post-conditions. | \n| `STATELOCK_HELD_RESPONSE_TIMEOUT` | 10 | Seconds to wait for the browser's response to a held commit action. | \n| `STATELOCK_VIOLATION_CLOSE_DELAY` | 0.5 | Seconds between the in-band violation error and closing the session. | \n| `STATELOCK_GUARD_READY_TIMEOUT` | 10 | Seconds an agent command on a new page or iframe waits for Statelock's setup there (the page guard). | \n| `STATELOCK_CDP_COMMAND_TIMEOUT` | 5 | Seconds Statelock's own CDP commands to the browser may take. | \n| `STATELOCK_ATTRIBUTION_TIMEOUT` | 1 | Seconds to wait for a request's initiator before it is logged as `unknown` . | \n| `STATELOCK_NAVIGATION_STALL_TIMEOUT` | 10 | Seconds after which a page load that Chromium started but never handed to the page guard ends the session. Chromium rarely does this (seen on a session's first page load, and later loads in that browser stall too). The agent's waiting command fails with \"Chromium stopped loading pages; start a new Statelock session\" instead of hanging, and the proxy log lists Chromium's processes and stderr. | \n| `STATELOCK_TRUSTED_SUBMIT_WINDOW` | 3 | Seconds after a submit from real input during which the form's navigation counts as trusted. | \n| `STATELOCK_AGENT_NAVIGATION_WINDOW` | 10 | Seconds after an agent navigation command during which a page load is attributed to it. | \n| `STATELOCK_MEMORY_SETTLE` | 0.5 | Seconds after a page load before the second capture of remembered fields. | \n\nEvery number must be positive, except `STATELOCK_PERCEPTION_RETRIES`, `STATELOCK_POST_CAPTURE_SETTLE`, `STATELOCK_VIOLATION_CLOSE_DELAY`, `STATELOCK_AGENT_SCRIPT_GRACE` and `STATELOCK_MEMORY_SETTLE`, which may be 0. An invalid value fails at startup.\n\n- **Rules.** Subclass`statelock.policy.rules.Rule` , decorate it with`@register_rule` , and load it with a`statelock.rules` entry point or`STATELOCK_RULE_MODULES` (see Custom rules).\n- **Storage.** Implement`statelock.audit.sink.ArtifactSink` . It receives versioned`ActionRecord` s (schema`\"4\"` ,`statelock.audit.records` ; the schema history is in that module) in sequence order.\n- **Plugins.** Expose an object with`name` and`setup(ctx)` in the`statelock.plugins` entry point group.`setup` can:\n  - replace or wrap `ctx.services.sink` ;\n  - subscribe to `ctx.services.events` (`violation` ,`action_written` ,`request_recorded` ,`review_requested` ,`review_decided` ,`session_closed` );\n  - add routes to `ctx.app` , which reach services with`statelock.app.get_services(request.app)` .\n- replace or wrap \n- **Perception.**`visual_assert` uses`LiteLLMPerceptionEvaluator` when`STATELOCK_PERCEPTION_MODEL` is set. For another backend, implement`statelock.policy.perception.PerceptionEvaluator` and set it on`get_services(app).evaluator.perception` in a plugin.\n\nStatelock Enclave is InferLink's commercial add-on for production and audit, built on the extension points above. It adds:\n\n- tamper-evident evidence storage (hash chains, signed anchors, S3 Object Lock);\n- a witness run by InferLink that countersigns each session's anchors in an append-only log, so an auditor who trusts neither you nor InferLink can check the evidence with `statelock-verify` , a verifier InferLink provides to auditors;\n- a forensic replay dashboard;\n- deployment inside your own cloud.\n\n```\nsrc/statelock/\n  core/        shared types: actions, state, verdicts, enums; URL scopes and request URLs (urls.py); CDP JSON helpers\n  policy/      policy schema, fields and remembered values, rule registry, triggers, evaluator,\n               request_access, cookie_access, regex checks (patterns.py), text normalization (text.py),\n               currencies (currency.py), custom rule loading (extensions.py), perception (VLM)\n  audit/       record schema, redaction, sequenced writer, ArtifactSink, LocalJsonSink\n  proxy/       bridge (session), browser (Chromium over a CDP pipe), connection, governor, replay (script clicks),\n               memory watcher, reporter, targets, inspector, guard, attribution, scripts, commands, cookies,\n               network_filter, requests, uploads, downloads, download_policy, files, tasks, pages, worlds,\n               js/ (guard.js, page_state.js, describe_element.js, focused_element.js, replay_point.js,\n               replay_hits.js, fetch.js)\n  client/      SDK: session URLs, connect_playwright / connect_playwright_sync (playwright.py), install() / install_sync() for\n               Playwright's file APIs (async files.py + install.py, sync files_sync.py + install_sync.py,\n               protocol in transfer.py, patching.py), connection (header path, internal),\n               requests (statelock_fetch), violations\n  integrations/  langchain.py (LangChain / CrewAI browser tools), browser_use.py (browser-use)\n  review/      human review: queue of paused actions, review API, /review page\n  demo/        /demo/finance, /demo/bank, /demo/erp (STATELOCK_DEMO=1 only)\n  app.py       create_app() factory and routes; re-exports get_services() and the authenticated_* dependencies\n  dependencies.py          FastAPI dependencies: authenticated_identity, authenticated_auditor\n  sessions.py, tokens.py   session URLs (POST /sessions, single-use tokens), /saved-sessions\n  saved_sessions.py        encrypted saved browser sessions (AES-GCM store, restore, save)\n  credentials.py           credential injection: secrets file, {{secret:name}} placeholders, scrubbing\n  auth.py      agent, reviewer and auditor API keys\n  __main__.py  the statelock command: the proxy (no subcommand), keygen, hash-key, session-url,\n               saved-sessions, check-sandbox, perception-check\n  settings.py, plugins.py, events.py, services.py, registry.py, wire.py, fileio.py\ntests/         unit tests; browser tests (test_browser_*.py, skipped without Chromium unless STATELOCK_REQUIRE_BROWSER=1);\n               helpers (conftest.py, helpers.py, fakes.py, browser_support.py, ojs_support.py); fixtures/perception\nexamples/      rogue_agent.py, finance_agent.py, portal_agent.py; ojs/ (Stagehand OJS agent, mock OJS site, its policy and custom rule,\n               POLICY_STUDIO.md); ojs-ts/ (the OJS agent in TypeScript on Stagehand v3)\ntools/policy-studio/  Policy Studio (experimental, run from a clone): server.py, draft_with_model.py, core.js, studio.js; test/\njs/            @statelock/client: the JavaScript/TypeScript SDK; src/ index.ts, sessions.ts, violations.ts, cdp.ts,\n               http.ts, mime.ts, playwright.ts (@statelock/client/playwright); test/\npolicies/      default.yaml\ndocker/        Dockerfile (targets: runtime, dev, ojs), seccomp profile, dev keys, dev secrets, README-sandbox.md\ncompose.yaml   the proxy; compose.dev.yaml adds demos, demo agents, tests, the vlm profile and the policy-studio profile\n.github/workflows/  ci.yml (lint, types, tests, extras, Policy Studio, JS), dco.yml (sign-off check), release.yml (PyPI and GitHub release)\n```\n\nCompose commands that use `compose.yaml` need `.env`, because it reads it (` env_file: .env`): run `cp .env.example .env` first.\n\nThe Docker Quick start uses `compose.dev.yaml`, which mounts public development keys and enables demo pages. To run `compose.yaml` alone, first install the `statelock-ai` CLI, configure a policy for your agent, and create its keys file:\n\n```\nstatelock keygen --agent-id my_agent --append keys.yaml\ndocker compose -f compose.yaml up -d --build\n```\n\nTo run the test suite in Docker, use the development override:\n\n```\ndocker compose -f compose.yaml -f compose.dev.yaml --profile test run --rm statelock-tests\n```\n\nTo drop the `-f` flags, set `COMPOSE_FILE=compose.yaml:compose.dev.yaml` in `.env`.\n\nTo try `visual_assert` with a local vision model (profile `vlm`; it needs only `compose.dev.yaml`):\n\n```\ndocker compose -f compose.dev.yaml --profile vlm up -d ollama\ndocker compose -f compose.dev.yaml --profile vlm run --rm ollama-pull        # download the model, once\ndocker compose -f compose.dev.yaml --profile vlm run --rm perception-tests\n```\n\n`perception-tests` runs `tests/test_perception_model.py` against `DEMO_VLM_MODEL` (default `qwen2.5vl:3b`, about 4 GB of memory). It prints the model's answers and checks only their form; set `DEMO_VLM_CHECK_ANSWERS=true` in `.env` to also require correct answers.\n\nOther demo services and profiles are listed in [compose.dev.yaml](https://github.com/inferlink/statelock/blob/main/compose.dev.yaml); the [OJS walkthrough](https://github.com/inferlink/statelock/blob/main/examples/ojs/README.md) covers the journal agent.\n\nFor a local development install:\n\n```\npip install -e \".[dev]\"\npython -m playwright install chromium\nruff check src tests examples tools/policy-studio && ruff format --check src tests examples tools/policy-studio && python -m mypy && python -m pytest -q\n```\n\n- The proxy runs on Linux and macOS (also under uvloop); Windows is not supported.\n- Python 3.10+ (the `browser-use` extra needs 3.11+). The Docker images use Python 3.12 (Playwright's Ubuntu 24.04 \"noble\" image).`mypy` is strict for`core` and`policy` .\n- The Docker image pins Playwright 1.64.0 (Chromium 156) and runs as `pwuser` . On Linux hosts,`./artifacts` must be writable by that user.\n- Injected JavaScript lives in `src/statelock/proxy/js/` .\n- CI (`.github/workflows/ci.yml` ) also runs the browser tests with`STATELOCK_REQUIRE_BROWSER=1` , so they fail instead of skipping without Chromium, plus the extras' tests, the Policy Studio tests and the JavaScript SDK.\n- Contributions: see [CONTRIBUTING.md](https://github.com/inferlink/statelock/blob/main/CONTRIBUTING.md) . Every commit needs a DCO sign-off (`git commit -s` ); a check on pull requests enforces it.\n- Security issues: see [SECURITY.md](https://github.com/inferlink/statelock/blob/main/SECURITY.md) .\n- Releases: the manual Release workflow publishes `statelock-ai` to PyPI and creates the GitHub release (see[CONTRIBUTING.md](https://github.com/inferlink/statelock/blob/main/CONTRIBUTING.md#releases) ).\n\nSee [ROADMAP.md](https://github.com/inferlink/statelock/blob/main/ROADMAP.md).", "url": "https://wpnews.pro/news/statelock-deterministic-guardrails-for-browser-agents-enforced-at-cdp", "canonical_source": "https://github.com/inferlink/statelock", "published_at": "2026-10-23 16:59:45+00:00", "updated_at": "2026-10-23 17:13:54.448371+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-tools", "developer-tools"], "entities": ["Statelock", "Inferlink", "Chrome DevTools Protocol", "Playwright", "browser-use", "Stagehand", "LangChain", "CrewAI"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/statelock-deterministic-guardrails-for-browser-agents-enforced-at-cdp", "markdown": "https://wpnews.pro/news/statelock-deterministic-guardrails-for-browser-agents-enforced-at-cdp.md", "text": "https://wpnews.pro/news/statelock-deterministic-guardrails-for-browser-agents-enforced-at-cdp.txt", "jsonld": "https://wpnews.pro/news/statelock-deterministic-guardrails-for-browser-agents-enforced-at-cdp.jsonld"}}