diff --git a/CAPABILITIES.md b/CAPABILITIES.md
index c934e587b1..26ff822804 100644
--- a/CAPABILITIES.md
+++ b/CAPABILITIES.md
@@ -295,11 +295,11 @@ improvising or retrying when something is off.
| See what your own agent gets wrong, with no risk: `openadapt shadow` is a loopback pass-through proxy beside the agent. It checks each write against the system of record (FHIR through `ensure_record`; SQL through the verifier's whole-database frame), checks each `CONFIRMED` again later, and writes `report.html` and `report.json`. It never blocks, changes or resends a write; the only delay is the read before a write (at most `hold_ms`) | [#1678](https://github.com/OpenAdaptAI/OpenAdapt/pull/1678) | Real app (Medplum 5.2.8, 37 cases through a fault proxy: scripted agent 18 of 18 harms caught, 0 correct writes refuted; Claude Sonnet 5.5 9 of 9 caught, 4 correct but corrected writes refuted; 0 writes by the shadow; 15 to 25 ms median added per write; setup 76.5 s machine time, 3 decisions); real app, SQL mode (Dolibarr 21 on MariaDB 11.4, 13 cases: 8 of 8 harms caught, 5 of 5 correct writes confirmed, 292 ms median added); synthetic (SQLite) | `openadapt shadow`, [SHADOW_MODE.md](packages/flow/docs/SHADOW_MODE.md), [SHADOW_RESULTS.md](packages/flow/benchmark/fhir_medplum/public/SHADOW_RESULTS.md) |
| Skill cache for framework agents: an agent in any framework does a browser task through compile-agent's harness as plain tools (`BrowserTools`); when the system of record shows exactly the task's record and the operator confirms the checks, the run is canaried, admitted and emitted as an MCP server. A repeat of the same task (instruction, record, app, contract, environment) calls that tool instead of the agent: no model calls, the program's checks and this task's record check on every run, eviction after a failed run | Released | Synthetic (MockMed, LangGraph on `claude-sonnet-5-5`, 3 trials, repeats on the same record: run 1 6 model calls and $0.022; runs 2 to 10 27/27 VERIFIED, 0 model calls, 7.8 s median); Trac 1.6: run 1 verified, not admitted | `openadapt_flow.integrations.agents.skills`, [benchmark/skill_cache](packages/flow/benchmark/skill_cache/README.md) |
| Verified write tools for agent frameworks: `verified(tool, contract=...)` wraps a Claude Agent SDK, OpenAI Agents SDK, LangGraph or CrewAI write tool in one line. The contract and any values the operator pins come from the operator, never the agent's arguments; the store is read before and after; the agent gets a receipt or a refusal, and a refused write is held instead of retried | Released | Synthetic (each framework's own agent loop with a scripted model, writing through the example app's order form over real HTTP: verified save, a save the app drops, a save it keeps twice); one real-model run per framework, opt-in | `openadapt_flow.integrations.agents`, extras `claude-agent-sdk`, `openai-agents`, `langgraph` (CrewAI: install `crewai` beside it); [AGENT_FRAMEWORKS.md](packages/flow/docs/AGENT_FRAMEWORKS.md) |
-| Browser teach refuses taught writes: a write an agent teaches needs a person's signed teach authorization, and this lane can't bind one, so `teach_start` with a contract is refused and an existing write tool doesn't run. Writes are taught through `serve --teach --teach-authorization`. The read-only lane (downloads) is unchanged | On main ([#1589](https://github.com/OpenAdaptAI/OpenAdapt/pull/1589), [#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Unit test of the refusal | `openadapt-agent serve --teach-browser`, [TEACH_AUTHORIZATION.md](packages/flow/docs/TEACH_AUTHORIZATION.md) |
+| Teach a browser write as a verified tool: with write contracts and a person's signed teach authorization (`serve --teach-browser --contracts DIR --teach-authorization STATEMENT`), the agent teaches a save once. Run 1 starts only when the statement covers the write (origin, contract, write adapter, the record's fields and values) and runs under it; it is verified in the system of record before the `taught_` tool exists. Every call checks the statement and the tool's signature again, makes the write and returns a verified receipt or a refusal. Sign-in values come from the server's environment and never reach the agent or the program. Controls in named iframes are found through their frame | On main ([#1589](https://github.com/OpenAdaptAI/OpenAdapt/pull/1589), [#1763](https://github.com/OpenAdaptAI/OpenAdapt/pull/1763)) | Synthetic (the example app's sign-in and order form in headless Chromium over MCP: verified save, a save the app drops, a save it keeps twice, a page that swaps the record during run 1; refusals for a missing, uncovering, expired, edited or wrongly signed statement) | `openadapt-agent serve --teach-browser --contracts --teach-authorization`, [VERIFY_WRITES.md](packages/flow/docs/VERIFY_WRITES.md#teach-a-write-as-a-tool) |
| Guard an agent you already run: attach to its own Chromium over CDP (one added line), hold each save until the record on screen is the one the task names, block it and tell the agent why when it isn't, read the save back; a run that reads back correctly becomes a draft program with a record check from scaffold-verifier, which a person confirms and admits, then a `taught_*` tool with no model calls. `--observe-only` never blocks | Released ([#1357](https://github.com/OpenAdaptAI/OpenAdapt/pull/1357), stacked on [#1335](https://github.com/OpenAdaptAI/OpenAdapt/pull/1335)) | Synthetic (MockMed, scripted Playwright agent, `effect_e2e` faults) | `openadapt-flow guard`, [GUARD_AN_AGENT.md](packages/flow/docs/GUARD_AN_AGENT.md) |
| Task ledger per job: rules locked when the job opens and enforced on every run, item status set only from OpenAdapt's own results, and yes-or-no questions a person answers on the phone or in Desktop, including when a run stops | Released ([#1332](https://github.com/OpenAdaptAI/OpenAdapt/pull/1332)) | Synthetic (30-item MockMed worklist, scripted agent, no model calls) | MCP tools `open_job`, `get_job`, `add_items`, `record_evidence`, `ask_user`, `get_answer`, `change_constraint`, `close_job`; [Track a long job](packages/agent/README.md#track-a-long-job) |
| Limit a teaching agent to the declared task on every teach path (`compile_agent`, `compile-agent`, teach over MCP, the airbag guard): typed text must be a declared value, links and browser requests to other origins are refused, writes are checked before they are sent, and the record is read back before a draft exists. Injected instructions in record notes, banners, tooltips, alt text and server responses are blocked or caught on MockMed through each path (scripted agent; a live-model mode is off by default). Self-repair refuses a control that leaves the app | Released ([#1359](https://github.com/OpenAdaptAI/OpenAdapt/pull/1359), [#1386](https://github.com/OpenAdaptAI/OpenAdapt/pull/1386)) | Synthetic | `openadapt_flow.teach_scope`, [THREAT_MODEL.md](packages/flow/docs/THREAT_MODEL.md) |
-| Teach authorization: a person signs what a teaching agent may do (task, origins, inputs, the one write, forbidden actions and controls) with their approver key, and every teach path is bound to it or refused. Verified with the approver roster's own verifier, so an unknown, removed or wrong key, an unpinned root, a changed statement or another task refuses. Admission of every JIT draft needs one (`teach_auth_not_recorded` otherwise), as do an agent-demonstrated fix's approval and self-repair of an agent-taught program; the airbag handoff's drafts and browser-teach writes are refused | On main ([#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Synthetic (real approver keys, real roster, real admission) | `openadapt-flow teach-authorization`, [TEACH_AUTHORIZATION.md](packages/flow/docs/TEACH_AUTHORIZATION.md) |
+| Teach authorization: a person signs what a teaching agent may do (task, origins, inputs, the one write, forbidden actions and controls) with their approver key, and every teach path is bound to it or refused. Verified with the approver roster's own verifier, so an unknown, removed or wrong key, an unpinned root, a changed statement or another task refuses. Admission of every JIT draft needs one (`teach_auth_not_recorded` otherwise), as do an agent-demonstrated fix's approval and self-repair of an agent-taught program; browser-teach writes are bound to it, and the airbag handoff's drafts are refused | On main ([#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Synthetic (real approver keys, real roster, real admission) | `openadapt-flow teach-authorization`, [TEACH_AUTHORIZATION.md](packages/flow/docs/TEACH_AUTHORIZATION.md) |
| Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | Released ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` |
| Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | Released ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `benchmark/harness/harness2/` (source checkout) |
| One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | Released ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` |
diff --git a/llms-full.txt b/llms-full.txt
index ec09e0ba23..446f4d059e 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -698,11 +698,11 @@ improvising or retrying when something is off.
| See what your own agent gets wrong, with no risk: `openadapt shadow` is a loopback pass-through proxy beside the agent. It checks each write against the system of record (FHIR through `ensure_record`; SQL through the verifier's whole-database frame), checks each `CONFIRMED` again later, and writes `report.html` and `report.json`. It never blocks, changes or resends a write; the only delay is the read before a write (at most `hold_ms`) | [#1678](https://github.com/OpenAdaptAI/OpenAdapt/pull/1678) | Real app (Medplum 5.2.8, 37 cases through a fault proxy: scripted agent 18 of 18 harms caught, 0 correct writes refuted; Claude Sonnet 5.5 9 of 9 caught, 4 correct but corrected writes refuted; 0 writes by the shadow; 15 to 25 ms median added per write; setup 76.5 s machine time, 3 decisions); real app, SQL mode (Dolibarr 21 on MariaDB 11.4, 13 cases: 8 of 8 harms caught, 5 of 5 correct writes confirmed, 292 ms median added); synthetic (SQLite) | `openadapt shadow`, [SHADOW_MODE.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SHADOW_MODE.md), [SHADOW_RESULTS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/fhir_medplum/public/SHADOW_RESULTS.md) |
| Skill cache for framework agents: an agent in any framework does a browser task through compile-agent's harness as plain tools (`BrowserTools`); when the system of record shows exactly the task's record and the operator confirms the checks, the run is canaried, admitted and emitted as an MCP server. A repeat of the same task (instruction, record, app, contract, environment) calls that tool instead of the agent: no model calls, the program's checks and this task's record check on every run, eviction after a failed run | Released | Synthetic (MockMed, LangGraph on `claude-sonnet-5-5`, 3 trials, repeats on the same record: run 1 6 model calls and $0.022; runs 2 to 10 27/27 VERIFIED, 0 model calls, 7.8 s median); Trac 1.6: run 1 verified, not admitted | `openadapt_flow.integrations.agents.skills`, [benchmark/skill_cache](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/skill_cache/README.md) |
| Verified write tools for agent frameworks: `verified(tool, contract=...)` wraps a Claude Agent SDK, OpenAI Agents SDK, LangGraph or CrewAI write tool in one line. The contract and any values the operator pins come from the operator, never the agent's arguments; the store is read before and after; the agent gets a receipt or a refusal, and a refused write is held instead of retried | Released | Synthetic (each framework's own agent loop with a scripted model, writing through the example app's order form over real HTTP: verified save, a save the app drops, a save it keeps twice); one real-model run per framework, opt-in | `openadapt_flow.integrations.agents`, extras `claude-agent-sdk`, `openai-agents`, `langgraph` (CrewAI: install `crewai` beside it); [AGENT_FRAMEWORKS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/AGENT_FRAMEWORKS.md) |
-| Browser teach refuses taught writes: a write an agent teaches needs a person's signed teach authorization, and this lane can't bind one, so `teach_start` with a contract is refused and an existing write tool doesn't run. Writes are taught through `serve --teach --teach-authorization`. The read-only lane (downloads) is unchanged | On main ([#1589](https://github.com/OpenAdaptAI/OpenAdapt/pull/1589), [#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Unit test of the refusal | `openadapt-agent serve --teach-browser`, [TEACH_AUTHORIZATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/TEACH_AUTHORIZATION.md) |
+| Teach a browser write as a verified tool: with write contracts and a person's signed teach authorization (`serve --teach-browser --contracts DIR --teach-authorization STATEMENT`), the agent teaches a save once. Run 1 starts only when the statement covers the write (origin, contract, write adapter, the record's fields and values) and runs under it; it is verified in the system of record before the `taught_` tool exists. Every call checks the statement and the tool's signature again, makes the write and returns a verified receipt or a refusal. Sign-in values come from the server's environment and never reach the agent or the program. Controls in named iframes are found through their frame | On main ([#1589](https://github.com/OpenAdaptAI/OpenAdapt/pull/1589), [#1763](https://github.com/OpenAdaptAI/OpenAdapt/pull/1763)) | Synthetic (the example app's sign-in and order form in headless Chromium over MCP: verified save, a save the app drops, a save it keeps twice, a page that swaps the record during run 1; refusals for a missing, uncovering, expired, edited or wrongly signed statement) | `openadapt-agent serve --teach-browser --contracts --teach-authorization`, [VERIFY_WRITES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/VERIFY_WRITES.md#teach-a-write-as-a-tool) |
| Guard an agent you already run: attach to its own Chromium over CDP (one added line), hold each save until the record on screen is the one the task names, block it and tell the agent why when it isn't, read the save back; a run that reads back correctly becomes a draft program with a record check from scaffold-verifier, which a person confirms and admits, then a `taught_*` tool with no model calls. `--observe-only` never blocks | Released ([#1357](https://github.com/OpenAdaptAI/OpenAdapt/pull/1357), stacked on [#1335](https://github.com/OpenAdaptAI/OpenAdapt/pull/1335)) | Synthetic (MockMed, scripted Playwright agent, `effect_e2e` faults) | `openadapt-flow guard`, [GUARD_AN_AGENT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/GUARD_AN_AGENT.md) |
| Task ledger per job: rules locked when the job opens and enforced on every run, item status set only from OpenAdapt's own results, and yes-or-no questions a person answers on the phone or in Desktop, including when a run stops | Released ([#1332](https://github.com/OpenAdaptAI/OpenAdapt/pull/1332)) | Synthetic (30-item MockMed worklist, scripted agent, no model calls) | MCP tools `open_job`, `get_job`, `add_items`, `record_evidence`, `ask_user`, `get_answer`, `change_constraint`, `close_job`; [Track a long job](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/agent/README.md#track-a-long-job) |
| Limit a teaching agent to the declared task on every teach path (`compile_agent`, `compile-agent`, teach over MCP, the airbag guard): typed text must be a declared value, links and browser requests to other origins are refused, writes are checked before they are sent, and the record is read back before a draft exists. Injected instructions in record notes, banners, tooltips, alt text and server responses are blocked or caught on MockMed through each path (scripted agent; a live-model mode is off by default). Self-repair refuses a control that leaves the app | Released ([#1359](https://github.com/OpenAdaptAI/OpenAdapt/pull/1359), [#1386](https://github.com/OpenAdaptAI/OpenAdapt/pull/1386)) | Synthetic | `openadapt_flow.teach_scope`, [THREAT_MODEL.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THREAT_MODEL.md) |
-| Teach authorization: a person signs what a teaching agent may do (task, origins, inputs, the one write, forbidden actions and controls) with their approver key, and every teach path is bound to it or refused. Verified with the approver roster's own verifier, so an unknown, removed or wrong key, an unpinned root, a changed statement or another task refuses. Admission of every JIT draft needs one (`teach_auth_not_recorded` otherwise), as do an agent-demonstrated fix's approval and self-repair of an agent-taught program; the airbag handoff's drafts and browser-teach writes are refused | On main ([#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Synthetic (real approver keys, real roster, real admission) | `openadapt-flow teach-authorization`, [TEACH_AUTHORIZATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/TEACH_AUTHORIZATION.md) |
+| Teach authorization: a person signs what a teaching agent may do (task, origins, inputs, the one write, forbidden actions and controls) with their approver key, and every teach path is bound to it or refused. Verified with the approver roster's own verifier, so an unknown, removed or wrong key, an unpinned root, a changed statement or another task refuses. Admission of every JIT draft needs one (`teach_auth_not_recorded` otherwise), as do an agent-demonstrated fix's approval and self-repair of an agent-taught program; browser-teach writes are bound to it, and the airbag handoff's drafts are refused | On main ([#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Synthetic (real approver keys, real roster, real admission) | `openadapt-flow teach-authorization`, [TEACH_AUTHORIZATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/TEACH_AUTHORIZATION.md) |
| Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | Released ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` |
| Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | Released ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `benchmark/harness/harness2/` (source checkout) |
| One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | Released ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` |
diff --git a/packages/flow/CHANGELOG.md b/packages/flow/CHANGELOG.md
index ffcdd9ddb4..fec49ab478 100644
--- a/packages/flow/CHANGELOG.md
+++ b/packages/flow/CHANGELOG.md
@@ -52,6 +52,17 @@ refuses a write when the record or its outcome cannot be established.
### Features
+- A teach authorization's forbidden controls are read from Chromium's accessibility tree, not by a script in the page. Every key (Enter, Space, any key, typing) is checked against the control that has focus when it arrives, in any frame, and Enter in a form field against the form's default button too, so a forbidden control reached by Tab or by implicit submission is refused like a click. Key chords are refused while controls are forbidden. A click is matched by every name its target goes by: the accessible name, the text of a control with no role (a `div` with a click handler), and the control a `` is for; the default button is the browser's own (`:default`), read in an isolated world. Behaviour change: on surfaces without a web page, a statement that forbids any control now refuses clicks and keys.
+
+- Browser teach writes again, bound to a person's signed teach authorization (`serve --teach-browser --teach-authorization`), for task `browser-tool.`. Run 1 opens no WebSocket and reaches only the statement's origins. Run 1 of a write starts only when the statement covers it: the app's origin, the contract as its record kind, a write adapter, run 1's inputs, and exactly the record fields the contract checks, with run 1's values. During run 1 the write adapter reads every write before it is sent. A taught tool keeps the statement and is signed with the local admission key; every call checks the statement again (signature, roster, purpose, task, expiry). The example app ships its write adapter (`openadapt_flow.agent.example_app:OrderWrites`). Docs: `docs/TEACH_AUTHORIZATION.md`, `docs/VERIFY_WRITES.md`.
+
+ Teaching and every later call stop on missing worker containment or an
+ observed worker violation and connect no WebSocket. Redirect hops receive
+ the wire check, and declared GET writes pin query field names, including
+ empty fields. Requests from independently targeted frames are refused.
+ Guarded clicks use the exact fragment point checked; keyboard checks also
+ read visible text.
+
- A code-as-action agent is now the default for the first run. Every save is
read back before that run becomes a replayable program. Form scripts can run
before an admitted API save.
diff --git a/packages/flow/docs/TEACH_AUTHORIZATION.md b/packages/flow/docs/TEACH_AUTHORIZATION.md
index f0a85ed057..b4573fe0ef 100644
--- a/packages/flow/docs/TEACH_AUTHORIZATION.md
+++ b/packages/flow/docs/TEACH_AUTHORIZATION.md
@@ -88,11 +88,19 @@ needs a person at an interactive terminal who types `yes`.
| JIT capture (`compile_agent(..., teach_authorization=TeachAuthority(...))`) | The capture's scope is bound from the statement; a `teach_scope` may only narrow it. The statement is kept beside the draft, and its digest is in the signed recorder block | Admission refuses (`teach_auth_not_recorded`) |
| `compile-agent` (`--teach-authorization`, `FirstRunCompiler`, `compile_first_run`, `SkillCache`) | Run 1's scope is the statement's alone; the command's `--expect`, `--on-screen` and `--allow-text` don't widen it. For `SkillCache`, the statement names `SkillCache.task_id(prompt, params)` | Admission refuses |
| Teach over MCP (`serve --teach --teach-authorization`; `teach_act` and `teach_run`) | Run 1 runs under the statement's scope, under its task id; the agent's declared inputs must be the statement's. `teach_run`'s scripts act through the same run 1, so every action and the save are checked against the same scope | Admission (`teach_admit`, `compile-agent admit`) refuses |
-| Browser teach writes (`serve --teach-browser`, `teach_start` with a contract) | Not bound: this lane has no teach scope guard to bind the statement to | Refused: no write tool is taught, and a write tool taught before doesn't run. The read-only lane sends no write and is unchanged |
+| Browser teach writes (`serve --teach-browser --teach-authorization --config`, `teach_start` with a contract) | Run 1 starts only when the statement verifies for task `browser-tool.` (a namespace of its own), expires within 90 days, names the app's origin, the contract as its `record_kind` and a write adapter, takes run 1's inputs, allows the contract's row count, and has exactly the record fields the contract's `record_written` match checks as its identity and fields, with run 1's values (a contract with other checks is refused). During run 1 the adapter reads every write before it is sent (and counts them), the browser reaches only the statement's origins and opens no WebSocket, only the statement's values may be typed, `press` takes no character keys, and the forbidden actions are watched. The tool keeps the verified copy and is signed with the local admission key in the config's `approvers.key_dir`; every later call checks the signature, the kept statement against the config's roster (purpose, task, expiry), the origin and the record kind. The statement's values, forbidden controls and actions bind run 1 only | No write tool is taught, and a write tool without a statement that holds doesn't run. The read-only lane sends no write and needs none |
| Agent-demonstrated fix (`teach_repair`, `serve --teach-authorization`) | Before the click, the desk verifies a statement that covers `repair` and names the program's workflow name as its task; the repair store keeps it. Approval checks it again, for that workflow | The fix is proposed, and `RepairStore.approve` refuses it |
| Self-repair of an agent-taught program (`admit_repair`) | The prior admission's authorization must cover `repair` and verify now; its statement is copied into the repaired bundle | The repair's admission (probe or full) refuses |
| Airbag handoff (`agent_guard.handoff`) | Not bound: the guarded run 1 runs under the guard's task file, and the handoff can't attach a person's signature to it yet | Admission refuses its draft (`teach_auth_not_recorded`). Teach the task through `compile-agent` or `serve --teach` with an authorization instead |
+Browser teaching and every later call use worker containment and refuse
+WebSocket connections. Missing containment support, a worker policy violation
+or an unexpected worker stops the run. Redirect hops receive the wire check
+before they are sent. A contract-declared GET write is counted and pinned by
+its query field names, including fields with empty values. Requests from an
+independently targeted frame are refused because the redirect guard cannot
+prove coverage there.
+
Every JIT draft is compiled from an agent's run, so admission requires the
authorization for every JIT draft, in each admission mode (a person, an
admitter key, an agent under a signed policy). Admission reads the signed
@@ -110,12 +118,18 @@ Programs and tools made before teach authorizations carry none, so:
- an admitted JIT program still runs, but a self-repair of it is refused
until a person teaches it again with an authorization that covers
`repair` and admits it.
-- a write tool taught in browser teach doesn't run.
+- a write tool taught in browser teach before teach authorizations doesn't
+ run. Teach it again under a statement.
- the benchmarks that compile an agent's run and `examples/jit/run_mockmed.py`
now sign a throwaway authorization for each run
(`teach_authorization_fixture.demo_authority`), standing in for the person.
## Limits
+- A browser-teach write tool is bound to its statement's values for run 1
+ only (the summary `sign` shows says so). Later calls take new inputs; the contract's read-back and the pinned
+ run-1 writes bound them, and each call checks the statement's signature,
+ roster, purpose, task and expiry again. When the statement expires (at
+ most 90 days on), the tool stops until it is taught again.
- The approver root comes from the caller: a flag, the environment or a
deployment config. A process that controls both the environment and the
@@ -133,9 +147,32 @@ Programs and tools made before teach authorizations carry none, so:
- A statement may be used for any number of teach runs of its task until it
expires; its nonce is not spent. Revoking the signer stops new admissions
and repairs, not programs already admitted.
-- Forbidden controls are matched by their accessible label, on web pages
- only; a click whose label can't be read is refused. Keyboard activation of
- a focused control is not checked against the list.
+- Forbidden controls are matched by every name the target goes by: its
+ accessible name from Chromium's accessibility tree, the text of the hit
+ element and three ancestors (a control with no role, such as a
+ ``), and for a `
` the name of the control it is for.
+ These are read over DevTools, the text and the label in an isolated
+ world, so a page script that tampers with how controls are read doesn't
+ change them; a page that renames a control through ARIA or its text
+ does, and against a hostile page the write adapter and the read-back are
+ what bind the save. A guarded click checks the first visible fragment
+ and uses that exact viewport point.
+ Every key (Enter, Space, any other key, typing) is checked when it
+ arrives against the control with focus, in any frame, and Enter in a
+ form field also against the button the browser would submit the form
+ with (`:default`, read in an isolated world). Focus and the default button
+ use the same names as the click check, including visible text. Key combinations
+ with a modifier are refused. A click with no position, a name that can't be
+ read, focus in a frame the tree can't see, or a surface without a web
+ page is refused when the statement forbids any control. In browser
+ teach, a page could still move focus between the check and the key.
+- A browser-teach tool taught again can be rolled back by an agent that can
+ write the tools folder: it can put back the older signed program and its
+ statement, which run until that statement expires.
+- A server that teaches browser writes signs each tool with the admission
+ key in the config's `approvers.key_dir`, so it reads that key. Run it as
+ a user the agent can't act as (Desktop's engine, or a service account);
+ an agent that can read the key can sign a tool program itself.
- An agent-demonstrated fix is recognized by its provenance, a JSON file in
the repair store that the store doesn't sign. A process that can write the
store can strip it, and the approval then asks for no authorization.
diff --git a/packages/flow/docs/VERIFY_WRITES.md b/packages/flow/docs/VERIFY_WRITES.md
index 96d6c82a7a..e0db8c0c8f 100644
--- a/packages/flow/docs/VERIFY_WRITES.md
+++ b/packages/flow/docs/VERIFY_WRITES.md
@@ -100,10 +100,24 @@ A verified result proves the system of record holds what was declared. It doesn'
## Teach a write as a tool
-A write an agent teaches needs a person's signed teach authorization
-([TEACH_AUTHORIZATION.md](TEACH_AUTHORIZATION.md)). Browser teach
-(`openadapt-agent serve --teach-browser`) can't bind one, so it refuses a
-`teach_start` that names a write contract, and a write tool taught there
-earlier doesn't run. Teach the write with `openadapt-agent serve --teach
---teach-authorization STATEMENT`: run 1 runs under the signed scope, and the
-program is admitted only with the signature. The read-only lane is unchanged.
+`openadapt-agent serve --teach-browser --contracts ./contracts --teach-authorization STATEMENT --config deploy.yaml` lets your agent teach a browser write once, against a contract, and call it again as a `taught_` tool. Each call makes the write and returns the same verified receipt or refusal.
+
+A person first signs what the agent may teach: a teach authorization ([TEACH_AUTHORIZATION.md](TEACH_AUTHORIZATION.md)) for the task `browser-tool.` (its own namespace: a statement for a one-time `serve --teach` run, `taught.`, doesn't cover a reusable tool). It must name the app's origin, the contract as its `record_kind`, a write adapter for the app, run 1's inputs, and exactly the record fields the contract checks, as its identity and fields, with run 1's values. The sign-in's path goes in `allowed_write_paths`. The server checks it against the approver roster in the deployment config's `approvers` section. The config's `approvers` section must name its `key_dir`, and that folder must hold the local admission key (`openadapt-flow jit keygen` sets one up), which signs each taught tool. The statement must expire within 90 days and allow as many rows as the contract checks. Run 1 starts only when it verifies and covers the write. While teaching, the write adapter reads every write before it is sent, the browser reaches only the statement's origins and opens no WebSocket, only the statement's values may be typed, and `press` takes only Enter, Tab, Escape and the arrow and page keys. Every later call checks the tool's signature and the kept statement again (signature, roster, purpose, task, expiry, origin, record kind) against the config's roster; the statement's identity, field values, forbidden controls and actions bind run 1 only, and later calls are bounded by the contract and run 1's pinned writes. Without one, or with one that doesn't cover the write, nothing is taught or run. The statement's values bind run 1 only; later calls take new inputs, bounded by the contract.
+
+For a taught write, the contract also lists the requests the write may send:
+
+```yaml
+writes: ["POST /login", "POST /orders"] # the sign-in and the save
+```
+
+The browser stops any other write before it is sent, while teaching and on every call. The tool sends exactly the writes it was taught, in order and with the same fields, and a call where the page tries anything else ends in `needs_review`, never `done`.
+
+Sign-in values stay with the server. They go only into a password field or another field of a sign-in form, never into an address, and no form of them (encoded or not) reaches the agent:
+
+```bash
+openadapt-agent serve --teach-browser --contracts ./contracts \
+ --teach-authorization STATEMENT --config deploy.yaml \
+ --sign-in username=PORTAL_USER --sign-in password=PORTAL_PASSWORD
+```
+
+The example app has a sign-in and a New Order form to try it on: `openadapt agent example-app`, with user `clerk` and password `portal-demo`. Its write adapter is `openadapt_flow.agent.example_app:OrderWrites`.
diff --git a/packages/flow/openadapt_flow/agent/browser_teach.py b/packages/flow/openadapt_flow/agent/browser_teach.py
index 57f216c590..33b1219cd3 100644
--- a/packages/flow/openadapt_flow/agent/browser_teach.py
+++ b/packages/flow/openadapt_flow/agent/browser_teach.py
@@ -71,6 +71,27 @@
"the write with openadapt-agent serve --teach --teach-authorization"
)
PROGRAM_SCHEMA = "openadapt.browser-tool/v1"
+#: What a taught write's program signature covers (``signed_as``), so it
+#: can't stand for another record the same key signs.
+PROGRAM_SIGNATURE_DOMAIN = "openadapt.browser-tool.write-program/v1"
+#: A taught write's statement must expire, and within this long of issue.
+MAX_WRITE_AUTHORIZATION_DAYS = 90
+#: Keys a taught write's run 1 may press: none types a character or pastes.
+SCOPED_KEYS = frozenset(
+ {
+ "Enter",
+ "Tab",
+ "Escape",
+ "ArrowUp",
+ "ArrowDown",
+ "ArrowLeft",
+ "ArrowRight",
+ "Home",
+ "End",
+ "PageUp",
+ "PageDown",
+ }
+)
PROGRAM_FILE = "program.json"
TAUGHT_PREFIX = "taught_"
BROWSER_TEACH_TOOLS = (
@@ -164,6 +185,221 @@ def _origin(url: str) -> str:
return f"{parts.scheme}://{parts.netloc}".lower()
+def _has_admission_key(key_dir: Path) -> bool:
+ """The local admission key pair exists (never created on the write lane)."""
+
+ from openadapt_flow.execute.keys import _KEY_NAME
+ from openadapt_flow.execute.keys import key_dir as keys_of
+
+ folder = keys_of(Path(key_dir))
+ return (folder / _KEY_NAME).is_file() and (folder / "ed25519.pub").is_file()
+
+
+def secrets_token() -> str:
+ import secrets as _secrets
+
+ return _secrets.token_hex(8)
+
+
+def _expiry_problem(statement: Any) -> Optional[str]:
+ """Why a taught write's statement is not bounded in time, or ``None``."""
+
+ from datetime import timedelta
+
+ if statement.expires_at is None:
+ return "has no expiry"
+ issued = datetime.strptime(statement.issued_at, "%Y-%m-%dT%H:%M:%SZ")
+ expires = datetime.strptime(statement.expires_at, "%Y-%m-%dT%H:%M:%SZ")
+ if expires - issued > timedelta(days=MAX_WRITE_AUTHORIZATION_DAYS):
+ return f"lasts more than {MAX_WRITE_AUTHORIZATION_DAYS} days"
+ return None
+
+
+#: Forbidden actions this lane enforces (paste: no paste action, and press
+#: refuses chords; drag, right click: no such action).
+_LANE_FORBIDDEN = frozenset(
+ {
+ "paste",
+ "drag",
+ "right_click",
+ "delete_request",
+ "new_window",
+ "download",
+ "file_upload",
+ }
+)
+
+
+def _lane_limits(statement: Any, contract: Any) -> Optional[str]:
+ """Why this lane can't hold run 1 to everything the statement signs."""
+
+ problem = _expiry_problem(statement)
+ if problem is not None:
+ return f"it {problem}; a taught write's authorization must expire"
+ unknown = set(statement.forbidden.actions) - _LANE_FORBIDDEN
+ if unknown:
+ return "it forbids actions this lane can't check: " + ", ".join(sorted(unknown))
+ counts = {
+ int(e["expected_count"] if e.get("expected_count") is not None else 1)
+ for e in contract.config.model_dump(mode="json").get("effects") or []
+ }
+ if counts and counts != {statement.writes.expected_rows}:
+ return (
+ f"it allows {statement.writes.expected_rows} rows; the contract "
+ f"checks {', '.join(map(str, sorted(counts)))}"
+ )
+ return None
+
+
+def _first_click_point(page: Any, el: Any) -> Optional[tuple[float, float]]:
+ """The first visible fragment's point, which the guarded click uses."""
+
+ if el is None:
+ return None
+ el.scroll_into_view_if_needed()
+ box = el.bounding_box()
+ if not box:
+ return None
+ geometry = el.evaluate(
+ """element => {
+ const box = element.getBoundingClientRect();
+ return {x: box.x, y: box.y, rects: Array.from(element.getClientRects(),
+ r => ({x: r.x, y: r.y, width: r.width, height: r.height}))};
+ }"""
+ )
+ viewport = page.viewport_size
+ if not viewport or not isinstance(geometry, Mapping):
+ return None
+ # Playwright's box uses the main viewport, including frame offsets.
+ dx, dy = box["x"] - geometry["x"], box["y"] - geometry["y"]
+ for rect in geometry["rects"]:
+ left, top = max(0, rect["x"] + dx), max(0, rect["y"] + dy)
+ right = min(viewport["width"], rect["x"] + dx + rect["width"])
+ bottom = min(viewport["height"], rect["y"] + dy + rect["height"])
+ if right - left >= 1 and bottom - top >= 1:
+ return (float(int((left + right) / 2)), float(int((top + bottom) / 2)))
+ return None
+
+
+def _check_forbidden(
+ page: Any, scope: Any, action: str, el: Any, key: Optional[str]
+) -> Optional[tuple[float, float]]:
+ """Refuse an action on (or a key reaching) a control the statement forbids.
+
+ The names come from Chromium's accessibility tree over DevTools, not
+ from the listing a page script built. A click or select is checked
+ at the first visible fragment, never the union's centre. A click uses
+ that exact checked point. A press focuses its element first, then checks
+ the control with focus and, for Enter, the
+ form's default button. Anything unreadable is refused. Raises
+ :class:`StepStopped`.
+ """
+
+ from openadapt_flow.teach_scope import (
+ control_labels_at,
+ key_target_labels,
+ normalize_structured,
+ )
+
+ labels: Optional[list[str]]
+ point: Optional[tuple[float, float]] = None
+ if action in ("click", "select"):
+ point = _first_click_point(page, el)
+ labels = (
+ control_labels_at(page, int(point[0]), int(point[1])) if point else None
+ )
+ elif action == "press":
+ if el is not None:
+ el.focus()
+ labels = key_target_labels(
+ page, enter=str(key or "").lower() in ("enter", "numpadenter")
+ )
+ else:
+ return
+ if labels is None:
+ raise StepStopped(
+ "the control can't be read, and the signed teach authorization "
+ "forbids some controls; nothing was done"
+ )
+ if any(
+ label and normalize_structured(label) in scope.forbidden_controls
+ for label in labels
+ ):
+ raise StepStopped(
+ "the signed teach authorization forbids this control; nothing was done"
+ )
+
+ return point
+
+
+def _contract_coverage(
+ contract: Any,
+ scope: Any,
+ inputs: Mapping[str, str],
+ secret_values: Mapping[str, str],
+) -> Optional[str]:
+ """Why a bound teach scope doesn't cover a contract's write, or ``None``.
+
+ The record fields the contract's effects match on must be exactly the
+ authorization's identity and fields, and each must have the value the
+ contract will check for run 1 (an input's value, or the literal).
+ """
+
+ from openadapt_flow.teach_scope import _same
+
+ effects = contract.config.model_dump(mode="json").get("effects") or []
+ expected: dict[str, str] = {}
+ for effect in effects:
+ effect = effect or {}
+ if effect.get("kind") != "record_written" or any(
+ effect.get(k) for k in ("field", "value", "new_records")
+ ):
+ # Only a record match is mapped to the statement; anything else
+ # the contract checks would go unsigned.
+ return (
+ f"the contract checks a {effect.get('kind')} effect this lane "
+ "can't map to the statement"
+ )
+ match = effect.get("match") or {}
+ if not isinstance(match, Mapping):
+ return "the contract's effects can't be read"
+ for field_name, spec in match.items():
+ if (
+ isinstance(spec, Mapping)
+ and spec.get("param") is None
+ and spec.get("literal") is not None
+ ):
+ value = str(spec["literal"])
+ elif isinstance(spec, Mapping) and isinstance(spec.get("param"), str):
+ param = spec["param"]
+ if param in secret_values:
+ return f"the contract checks {field_name} with a sign-in value"
+ if param not in inputs:
+ return f"run 1 gives no value for {field_name}"
+ value = str(inputs[param])
+ elif isinstance(spec, (str, int, float, bool)):
+ value = str(spec)
+ else:
+ return f"the contract's check of {field_name} isn't a plain value"
+ if expected.setdefault(str(field_name), value) != value:
+ return f"the contract checks {field_name} twice, differently"
+ if not expected:
+ return "the contract checks no record fields"
+ signed = {**dict(scope.fields), **dict(scope.record)}
+ if set(signed) != set(expected):
+ return (
+ "its identity and fields ("
+ + ", ".join(sorted(signed))
+ + ") aren't the fields the contract checks ("
+ + ", ".join(sorted(expected))
+ + ")"
+ )
+ for field_name, value in expected.items():
+ if not _same(signed[field_name], value):
+ return f"run 1's {field_name} isn't the value it allows"
+ return None
+
+
def _norm(text: str) -> str:
return re.sub(r"\s+", " ", str(text or "")).strip().lower()
@@ -564,13 +800,20 @@ def __init__(
allowed_writes: Sequence[str] = (),
pinned: Optional[Sequence[Mapping[str, str]]] = None,
secrets: Sequence[str] = (),
+ scope: Any = None,
) -> None:
from playwright.sync_api import sync_playwright
self.origin = origin
+ #: Run 1 of a taught write: the teach scope bound from the person's
+ #: signed teach authorization. Every write is read by its write
+ #: adapter before it is sent (record, fields, values).
+ self.scope = scope
self.allow_writes = allow_writes
self.secrets = [v for v in secrets if v]
self.blocked: list[str] = []
+ self._pending_sockets: list[Any] = []
+ self._closed = False
self.downloads: list[Any] = []
#: What may leave the browser (the shared wire check): no writes on the
#: read lane; on the write lane only the contract's writes, and on a
@@ -608,6 +851,59 @@ def __init__(
self.page = self.context.new_page()
self.page.set_default_timeout(_STEP_TIMEOUT_MS)
self.page.on("download", lambda d: self.downloads.append(d))
+ #: Writes run 1's write adapter attributed (record writes).
+ self.claimed = 0
+ try:
+ # Request routes never see WebSockets, including on later calls.
+ # No browser-teach context connects one to a server.
+ if not hasattr(self.context, "route_web_socket"):
+ raise BridgeError(
+ "this Playwright can't stop WebSockets (1.48 or newer is "
+ "needed), so a browser teach run can't be held"
+ )
+
+ def _refuse_socket(ws: Any) -> None:
+ self.blocked.append(f"a WebSocket to {_origin(ws.url)}")
+ # Playwright's sync callback is on its async dispatcher.
+ # Closing here reenters that dispatcher and can deadlock. A
+ # routed socket has no server connection unless explicitly
+ # connected; keep it mocked and close from the outer check.
+ self._pending_sockets.append(ws)
+
+ self.context.route_web_socket("**/*", _refuse_socket)
+ from openadapt_flow.backends.worker_containment import contain_workers
+
+ # Newest route first: containment hands only this page's requests
+ # to the wire guard installed above. Install before any navigation.
+ self.containment = contain_workers(self.page, self.blocked.append)
+ if not self.containment.ok:
+ raise BridgeError(
+ self.containment.problem or self.containment.violations[0]
+ )
+ from openadapt_flow.backends.redirect_guard import guard_redirects
+
+ self.redirects = guard_redirects(
+ self.page, self._guard, self.blocked.append
+ )
+ if not self.redirects.ok:
+ raise BridgeError(
+ self.redirects.problem or self.redirects.violations[0]
+ )
+ except Exception:
+ self.close()
+ raise
+ if scope is not None:
+ forbidden = scope.forbidden_actions
+ if "new_window" in forbidden:
+ self.context.on(
+ "page", lambda _p: self.blocked.append("a new window or tab")
+ )
+ if "download" in forbidden:
+ self.page.on("download", lambda _d: self.blocked.append("a download"))
+ if "file_upload" in forbidden:
+ self.page.on(
+ "filechooser", lambda _f: self.blocked.append("a file chooser")
+ )
self.page.on("dialog", self._dialog)
downloads_dir.mkdir(parents=True, exist_ok=True)
@@ -620,8 +916,44 @@ def _dialog(self, dialog: Any) -> None:
except Exception: # noqa: BLE001 - already handled
pass
+ def _frame_routes_covered(self, request: Any) -> bool:
+ if getattr(request, "redirect_guarded", False):
+ return True # this request came from the installed redirect session
+ from playwright.sync_api import Error
+
+ shared = (
+ "This frame does not have a separate CDP session, it is a part "
+ "of the parent frame's session"
+ )
+ try:
+ frame = request.frame
+ for _ in range(64):
+ if frame is self.page.main_frame:
+ return True
+ if frame is None:
+ return False
+ try:
+ session = self.context.new_cdp_session(frame)
+ except Error as exc:
+ # This explicit result comes from Playwright's frame-session
+ # map. Unknown errors never establish parent coverage.
+ first = str(exc).splitlines()[0]
+ if first != shared and not first.endswith(": " + shared):
+ return False
+ frame = frame.parent_frame
+ else:
+ session.detach()
+ return False # a separate target has no installed hop guard
+ except Exception: # noqa: BLE001 - unknown frame coverage refuses
+ return False
+ return False
+
def _guard(self, route: Any) -> None:
request = route.request
+ if not self._frame_routes_covered(request):
+ self.blocked.append("a frame whose redirected requests cannot be checked")
+ route.abort()
+ return
if (
request.method.upper() in READ_METHODS
and request.is_navigation_request()
@@ -632,13 +964,81 @@ def _guard(self, route: Any) -> None:
self.blocked.append(f"leaving {self.origin} for {_origin(request.url)}")
route.abort()
return
+ if (
+ self.scope is not None
+ and request.method.upper() in READ_METHODS
+ and not self.scope.allows_origin(request.url)
+ and urlsplit(request.url).scheme in ("http", "https", "ws", "wss")
+ ):
+ # Run 1 reaches only the origins the statement names.
+ self.blocked.append(
+ f"{_origin(request.url)}, an origin the signed teach "
+ "authorization doesn't name"
+ )
+ route.abort()
+ return
+ if (
+ self.scope is not None
+ and self.wire.is_write(request)
+ and self._wire_allows(request)
+ ):
+ # A write the wire check would send: the authorization's write
+ # adapter reads it first (record, fields, values).
+ try:
+ body = request.post_data_buffer
+ refusal = self.scope.request_refusal(
+ request.method, request.url, body, write=True
+ )
+ attributed = (
+ refusal is None
+ and self.scope.write_adapter.claim(
+ request.method.upper(), request.url, body
+ )
+ is not None
+ )
+ except Exception: # noqa: BLE001 - an unreadable write is refused
+ refusal, attributed = "write_unreadable", False
+ if attributed:
+ self.claimed += 1
+ if self.claimed > self.scope.expected_rows:
+ refusal = "write_more_rows_than_signed"
+ if refusal is not None:
+ self.blocked.append(
+ f"{request.method.upper()} {urlsplit(request.url).path}, "
+ f"outside the signed teach authorization ({refusal})"
+ )
+ route.abort()
+ return
self.wire.handle(route)
+ def _wire_allows(self, request: Any) -> bool:
+ try:
+ return self.wire.judge(request) is None
+ except Exception: # noqa: BLE001 - the wire check refuses it itself
+ return False
+
+ def check_guards(self) -> None:
+ if not self._closed:
+ pending, self._pending_sockets = self._pending_sockets, []
+ for ws in pending:
+ try:
+ ws.close(code=1008, reason="browser teach refuses WebSockets")
+ except Exception: # noqa: BLE001 - a refusal still halts the run
+ self.blocked.append("a refused WebSocket could not be closed")
+ for state in (self.containment, self.redirects):
+ for reason in ([state.problem] if state.problem else []) + state.violations:
+ if reason not in self.blocked:
+ self.blocked.append(reason)
+
def settle(self) -> None:
try:
self.page.wait_for_load_state("domcontentloaded", timeout=_STEP_TIMEOUT_MS)
+ # Dispatch browser policy events and the captured binding before
+ # judging completion, even on a document that was already loaded.
+ self.page.wait_for_timeout(0)
except Exception: # noqa: BLE001 - an aborted or download navigation
pass
+ self.check_guards()
def close(self) -> None:
for closer in (self.context.close, self._browser.close, self._pw.stop):
@@ -646,6 +1046,8 @@ def close(self) -> None:
closer()
except Exception: # noqa: BLE001 - closing is best effort
pass
+ self._closed = True
+ self._pending_sockets.clear()
# -- the downloaded file and its checks --------------------------------------------------
@@ -766,6 +1168,10 @@ class _Teaching:
thread: _BrowserThread
#: The write contract a taught write is verified against, by name.
contract: Optional[str] = None
+ #: A taught write: the verified teach authorization, its statement and
+ #: the teach scope bound from it.
+ authorization: Optional[tuple[Any, Path]] = None
+ scope: Any = None
pending: Any = None
secret_values: dict[str, str] = field(default_factory=dict)
secret_typed: bool = False
@@ -842,7 +1248,9 @@ def _run_steps(
# Guard the page's WebSockets from its first document (wire check).
prepare_ws_guard(page)
page.goto(program["url"], wait_until="domcontentloaded")
+ browser.settle()
for i, step in enumerate(program["steps"], 1):
+ browser.check_guards()
if browser.blocked:
raise StepStopped(
f"the page tried {browser.blocked[0]}; it was blocked"
@@ -851,25 +1259,32 @@ def _run_steps(
value = step.get("value") or {}
text = inputs[value["input"]] if "input" in value else value.get("text", "")
try:
+ if target is not None:
+ el = _resolve(page, target, _APPEAR_MS)
+ else:
+ el = None
+ browser.check_guards()
+ if browser.blocked:
+ raise StepStopped(
+ f"the page tried {browser.blocked[0]}; it was blocked"
+ )
if step["op"] == "goto":
page.goto(
program["origin"] + step["url"], wait_until="domcontentloaded"
)
elif step["op"] == "fill":
- _resolve(page, target, _APPEAR_MS).fill(text)
+ el.fill(text)
elif step["op"] == "select":
- el = _resolve(page, target, _APPEAR_MS)
try:
el.select_option(label=text, timeout=2000)
except Exception: # noqa: BLE001 - the input names a value
el.select_option(value=text)
elif step["op"] == "press":
if target:
- _resolve(page, target, _APPEAR_MS).press(step["key"])
+ el.press(step["key"])
else:
page.keyboard.press(step["key"])
elif step["op"] == "click":
- el = _resolve(page, target, _APPEAR_MS)
if step.get("download"):
with page.expect_download(timeout=_STEP_TIMEOUT_MS) as info:
el.click()
@@ -891,10 +1306,11 @@ def _run_steps(
except StepStopped as exc:
out.stopped = str(exc)
finally:
+ browser.close()
+ browser.check_guards()
out.writes_sent = list(browser.writes_sent)
out.blocked = list(browser.blocked)
out.dialogs = list(browser.dialogs)
- browser.close()
out.seconds = round(time.monotonic() - started, 2)
return out
@@ -973,18 +1389,33 @@ def __init__(
contracts: Optional[Mapping[str, Any]] = None,
secrets: Optional[Mapping[str, str]] = None,
verify_dir: Optional[Path] = None,
+ teach_authorization: Any = None,
+ approvers: Any = None,
) -> None:
"""``contracts`` (name -> :class:`~openadapt_flow.verifier.Contract`)
turns on taught writes: a task that names one may write to the app,
and every run of it is verified against it. ``secrets`` maps an input
name to the environment variable that holds its value (a sign-in), so
- the value never reaches the agent, the program or a result."""
+ the value never reaches the agent, the program or a result.
+
+ ``teach_authorization`` (a
+ :class:`~openadapt_flow.teach_authorization.TeachAuthority`) is what
+ lets a write be taught: run 1 starts only when the statement verifies
+ and its permitted write covers the contract's (see
+ :meth:`_authorize_write`), and run 1 runs under the teach scope bound
+ from it. Every later call of the tool checks the kept statement again
+ (:meth:`_write_refusal`). Without one, no write is taught.
+ ``approvers`` (a :class:`~openadapt_flow.teach_authorization.TeachAuthority`
+ whose statement is unused) pins the roster for running taught tools
+ when the server teaches nothing new; it must name its key directory."""
self.tools_dir = Path(tools_dir or default_tools_dir()).expanduser().resolve()
self.files_dir = Path(files_dir or default_files_dir()).expanduser().resolve()
self.headless = headless
self.contracts = dict(contracts or {})
self.secrets = dict(secrets or {})
+ self.teach_authorization = teach_authorization
+ self.approvers = approvers
for key in self.secrets:
if not _NAME.fullmatch(key):
raise ValueError(f"secret input names are a-z, 0-9 and _ ({key!r})")
@@ -1006,6 +1437,9 @@ def __init__(
program.get("name", "")
):
raise ValueError("not a browser tool program")
+ if program["name"] != path.parent.name:
+ # A tool is its folder: another folder can't stand in for it.
+ raise ValueError("its name is not its folder's")
except (OSError, ValueError) as exc:
print(f"openadapt-agent: skipped {path}: {exc}", file=sys.stderr)
continue
@@ -1333,11 +1767,6 @@ def _start(self, args: Mapping[str, Any]) -> dict[str, Any]:
)
contract_name = args.get("contract")
contract = None
- if contract_name is not None:
- # A taught write is an agent teaching a write with no person's
- # signed teach authorization bound to it (this lane has no teach
- # scope guard), so it is refused. docs/TEACH_AUTHORIZATION.md.
- raise BridgeError(TAUGHT_WRITE_REFUSED)
if contract_name is not None:
contract = self.contracts.get(str(contract_name))
if contract is None:
@@ -1354,7 +1783,11 @@ def _start(self, args: Mapping[str, Any]) -> dict[str, Any]:
'can\'t be bounded: the operator adds writes: ["POST /path", ...] '
"to it"
)
+ authorized: Optional[tuple[Any, Path, Any]] = None
if contract is not None:
+ authorized = self._authorize_write(
+ name, url, contract, inputs, secret_values
+ )
from openadapt_flow.agent.verify_tools import contract_params
missing = [
@@ -1381,6 +1814,8 @@ def _start(self, args: Mapping[str, Any]) -> dict[str, Any]:
files_dir=self.files_dir / name,
thread=thread,
contract=str(contract_name) if contract is not None else None,
+ authorization=authorized[:2] if authorized is not None else None,
+ scope=authorized[2] if authorized is not None else None,
secret_values=secret_values,
)
if contract is not None:
@@ -1407,18 +1842,21 @@ def open_page() -> str:
allow_writes=run.contract is not None,
allowed_writes=contract.writes if contract is not None else (),
secrets=list(secret_values.values()),
+ scope=run.scope,
)
from openadapt_flow.backends.playwright_backend import prepare_ws_guard
# Guard the page's WebSockets from its first document (wire check).
prepare_ws_guard(run.browser.page)
run.browser.page.goto(url, wait_until="domcontentloaded")
+ run.browser.settle()
return self._listing(run)
try:
listing = thread.call(open_page)
except Exception as exc:
- thread.call(lambda: run.browser and run.browser.close())
+ if not run.ended:
+ thread.call(lambda: run.browser and run.browser.close())
thread.stop()
if isinstance(exc, BridgeError):
raise
@@ -1459,8 +1897,22 @@ def _secret_values(self) -> dict[str, str]:
values[key] = value
return values
+ def _check_browser(self, run: _Teaching) -> None:
+ assert run.browser is not None
+ run.browser.check_guards()
+ if run.browser.blocked:
+ what = run.browser.blocked[0]
+ run.ended = "blocked"
+ run.browser.close()
+ run.thread.stop()
+ raise BridgeError(
+ f"run 1 ended: the page tried {what}; it was not sent. Nothing "
+ "was compiled. Start again and avoid that control."
+ )
+
def _listing(self, run: _Teaching) -> str:
assert run.browser is not None
+ self._check_browser(run)
raw = _observe_page(run.browser.page, run.browser.origin)
if run.secret_typed:
# Once a sign-in value is typed, no address is shown with its query.
@@ -1538,6 +1990,7 @@ def _act_on_thread(
browser = run.browser
assert browser is not None
page = browser.page
+ self._check_browser(run)
if run.file is not None:
raise BridgeError("the file has downloaded; call teach_finish")
step: dict[str, Any] = {"op": "fill" if action == "type" else action}
@@ -1563,6 +2016,12 @@ def _act_on_thread(
key = str(args.get("key") or "")
if not key:
raise BridgeError("press needs key")
+ if run.scope is not None and key not in SCOPED_KEYS:
+ raise BridgeError(
+ "refused: in a taught write, press takes only Enter, Tab, "
+ "Escape and the arrow and page keys; type values with type. "
+ "Nothing was pressed"
+ )
if args.get("ref"):
c = self._control(run, args.get("ref"))
else:
@@ -1602,6 +2061,7 @@ def _act_on_thread(
f"{args['ref']} is a {c['role']}, not a text field"
)
text, step["value"] = self._value(run, args)
+ self._check_typed(run, text, step["value"])
if step["value"].get("secret") and not (
c.get("type") == "password"
or (c.get("method") == "POST" and c.get("sign_in"))
@@ -1618,6 +2078,17 @@ def _act_on_thread(
if c["tag"] != "select":
raise BridgeError(f"{args['ref']} is not a drop-down")
text, step["value"] = self._value(run, args)
+ self._check_typed(run, text, step["value"])
+ if c is not None and run.scope is not None:
+ from openadapt_flow.teach_scope import normalize_structured
+
+ if normalize_structured(str(c.get("label") or "")) in (
+ run.scope.forbidden_controls
+ ):
+ raise BridgeError(
+ "refused: the signed teach authorization forbids this control; "
+ "nothing was done"
+ )
target = None
if c is not None:
target = _unique_target(page, c)
@@ -1633,8 +2104,19 @@ def _act_on_thread(
# The same resolution a compiled run uses, so what works here
# works there.
el = _resolve(page, target, 2000) if target is not None else None
+ click_point = None
+ if run.scope is not None and run.scope.forbidden_controls:
+ click_point = _check_forbidden(
+ page, run.scope, action, el, step.get("key")
+ )
+ self._check_browser(run)
if action == "click":
- el.click()
+ if click_point is not None:
+ # Never let a second default-point choice reach a control
+ # different from the one the guard just checked.
+ page.mouse.click(*click_point)
+ else:
+ el.click()
elif action == "type":
el.fill(text)
elif action == "select":
@@ -1663,17 +2145,7 @@ def _act_on_thread(
if len(browser.downloads) > before:
break
page.wait_for_timeout(100)
- if browser.blocked:
- what = browser.blocked[0]
- # On the browser thread already: close here, not through _end,
- # which would wait on this same thread.
- run.ended = "blocked"
- browser.close()
- run.thread.stop()
- raise BridgeError(
- f"run 1 ended: the page tried {what}; it was not sent. Nothing "
- "was compiled. Start again and avoid that control."
- )
+ self._check_browser(run)
run.steps.append(step)
result: dict[str, Any] = {}
if len(browser.downloads) > before:
@@ -1737,6 +2209,233 @@ def _status(self) -> dict[str, Any]:
),
}
+ # -- the teach authorization (taught writes) ---------------------------------
+
+ @staticmethod
+ def write_task_id(name: str) -> str:
+ """The task a teach authorization names for the write tool ``name``."""
+
+ return f"browser-tool.{name}"
+
+ def _trust(self) -> tuple[Path, Optional[str], Any]:
+ """The roster's key directory, root and heads for taught writes.
+
+ From ``approvers`` (the deployment config's section, which a running
+ tool needs) or else the teach authorization's own pin. The key
+ directory must be named there: one that falls back to
+ ``$OPENADAPT_JIT_KEY_DIR`` could be chosen by the agent, and it holds
+ the key that signs taught programs.
+ """
+
+ from openadapt_flow import teach_authorization as ta
+
+ source = self.approvers or self.teach_authorization
+ if source is None:
+ raise ta.TeachAuthorizationError(
+ "teach_auth_missing",
+ "the server names no approvers (--config) and no teach authorization",
+ )
+ if getattr(source, "key_dir", None) is None:
+ raise ta.TeachAuthorizationError(
+ "teach_auth_identity_unverified",
+ "the deployment config's approvers section names no key_dir",
+ )
+ key_dir, root = source.trust()
+ return key_dir, root, getattr(source, "at_least", None)
+
+ def _authorize_write(
+ self,
+ name: str,
+ url: str,
+ contract: Any,
+ inputs: Mapping[str, str],
+ secret_values: Mapping[str, str],
+ ) -> tuple[Any, Path, Any]:
+ """Check the person's teach authorization covers this write; bind its scope.
+
+ Returns ``(verification, statement path, teach scope)``. Refuses
+ (:class:`BridgeError`) unless the statement verifies against the
+ approver roster now, for purpose ``teach`` and task
+ ``browser-tool.``, has not expired, names this app's origin, names the
+ contract as its ``record_kind``, names a write adapter, takes exactly
+ this run's inputs, and its identity and fields are exactly the record
+ fields the contract checks, with this run's values.
+ """
+
+ from openadapt_flow import teach_authorization as ta
+
+ def refuse(code: str, why: str) -> BridgeError:
+ return BridgeError(
+ f"refused ({code}): {why}. A taught write needs a person's signed "
+ "teach authorization that covers it; nothing was done"
+ )
+
+ authority = self.teach_authorization
+ if authority is None:
+ raise refuse(
+ "teach_auth_missing",
+ "the server was started without --teach-authorization",
+ )
+ if getattr(authority, "key_dir", None) is None:
+ raise refuse(
+ "teach_auth_identity_unverified",
+ "the deployment config's approvers section names no key_dir",
+ )
+ try:
+ # The bytes verified are the bytes kept with the tool.
+ staged = authority.kept_in(
+ self.verify_dir / "teach-authorization" / secrets_token()
+ )
+ verification = staged.verify(
+ purpose="teach", task_id=self.write_task_id(name)
+ )
+ scope = ta.bind(
+ verification, dict(inputs), write_adapter=authority.write_adapter
+ )
+ except ta.TeachAuthorizationError as exc:
+ raise refuse(exc.code, str(exc)) from None
+ statement = verification.statement
+ assert statement is not None
+ problem = _lane_limits(statement, contract)
+ if problem is not None:
+ raise refuse("teach_auth_write_not_covered", problem)
+ try:
+ key_dir, _root, _heads = self._trust()
+ except ta.TeachAuthorizationError as exc:
+ raise refuse(exc.code, str(exc)) from None
+ if not _has_admission_key(key_dir):
+ raise refuse(
+ "teach_auth_no_admission_key",
+ "the approvers' key directory has no admission key to sign the "
+ "tool with (openadapt-flow jit keygen sets one up)",
+ )
+ if _origin(url) not in statement.origins:
+ raise refuse("teach_auth_write_not_covered", "it doesn't name this app")
+ if statement.writes.record_kind != contract.name:
+ raise refuse(
+ "teach_auth_write_not_covered",
+ f"it is for {statement.writes.record_kind} writes, not the "
+ f"{contract.name} contract",
+ )
+ if scope.write_adapter is None:
+ raise refuse(
+ "teach_auth_write_not_covered",
+ "it names no write adapter, so run 1's writes can't be read "
+ "before they are sent",
+ )
+ problem = _contract_coverage(contract, scope, inputs, secret_values)
+ if problem is not None:
+ raise refuse("teach_auth_write_not_covered", problem)
+ return verification, Path(staged.statement), scope
+
+ def _seal_program(
+ self, program: dict[str, Any], authorization: tuple[Any, Path], folder: Path
+ ) -> dict[str, Any]:
+ """Keep the statement with the tool, record it, and sign the program.
+
+ The signature is the local admission key's (in the roster's key
+ directory, kept away from the agent), so an edited ``program.json``
+ or a statement copied into another tool doesn't run.
+ """
+
+ import shutil
+
+ from openadapt_flow import teach_authorization as ta
+ from openadapt_flow.jit.lifecycle import _sign_record
+
+ verification, statement = authorization
+ key_dir, _root, _heads = self._trust()
+ if not _has_admission_key(key_dir):
+ # Never create a signing key here: it would be one nobody set up.
+ raise BridgeError(
+ "the approvers' key directory has no admission key; set it up "
+ "(openadapt-flow jit keygen) before teaching writes. No tool was "
+ "added"
+ )
+ kept = folder / ta.KEPT_DIR
+ if kept.exists():
+ # Taught again (replace): run 1 verified under the new statement.
+ shutil.rmtree(kept)
+ ta.copy_into(statement, kept)
+ program = {
+ **program,
+ "signed_as": PROGRAM_SIGNATURE_DOMAIN,
+ "task_id": self.write_task_id(str(program["name"])),
+ "teach_authorization": ta.record(verification),
+ }
+ program["signature"] = _sign_record(program, key_dir)
+ return program
+
+ def _write_refusal(self, program: Mapping[str, Any]) -> Optional[str]:
+ """Why a taught write tool may not run now, or ``None``.
+
+ Checked before every call: the program's signature, then the kept
+ statement against the approver roster as it is now (a removed key
+ stops the tool), its digest, purpose ``teach``, the tool's task, its
+ expiry, and that it still names the program's origin and contract.
+ """
+
+ from openadapt_flow import teach_authorization as ta
+ from openadapt_flow.jit.lifecycle import _record_signed
+
+ name = str(program.get("name", ""))
+ try:
+ key_dir, root, heads = self._trust()
+ except ta.TeachAuthorizationError as exc:
+ return f"this write tool needs a teach authorization ({exc.code})"
+ if (
+ program.get("signed_as") != PROGRAM_SIGNATURE_DOMAIN
+ or not _record_signed(program, key_dir)
+ or program.get("task_id") != self.write_task_id(name)
+ or not (self.tools_dir / name / PROGRAM_FILE).is_file()
+ ):
+ return "this write tool's program changed after it was taught"
+ kept = self.tools_dir / name / ta.KEPT_DIR / ta.STATEMENT_FILE
+ try:
+ recorded = program.get("teach_authorization")
+ if not isinstance(recorded, Mapping) or not recorded:
+ raise ta.TeachAuthorizationError(
+ "teach_auth_not_recorded", "the tool names no teach authorization"
+ )
+ verified = ta.require(
+ kept,
+ purpose="teach",
+ key_dir=key_dir,
+ root=root,
+ task_id=self.write_task_id(name),
+ at_least=heads,
+ )
+ if verified.sha256 != recorded.get("sha256"):
+ raise ta.TeachAuthorizationError(
+ "teach_auth_digest_mismatch",
+ "the kept statement is not the one the tool recorded",
+ )
+ except ta.TeachAuthorizationError as exc:
+ return (
+ f"this write tool's teach authorization doesn't hold now ({exc.code})"
+ )
+ st = verified.statement
+ if (
+ st is None
+ or program.get("origin") not in st.origins
+ or st.writes.record_kind != (program.get("result") or {}).get("contract")
+ ):
+ return "this write tool goes beyond its teach authorization"
+ if _expiry_problem(st) is not None:
+ return f"this write tool's teach authorization {_expiry_problem(st)}"
+ return None
+
+ def _check_typed(self, run: _Teaching, text: str, value: Mapping[str, Any]) -> None:
+ """Run 1 of a taught write types only what the authorization allows."""
+
+ if run.scope is None or value.get("secret"):
+ return
+ if text not in run.scope.typeable:
+ raise BridgeError(
+ "refused: the signed teach authorization doesn't allow typing "
+ "that value; nothing was typed"
+ )
+
def _finish_write(self) -> dict[str, Any]:
"""End a taught write: verify run 1's write, then add the tool."""
@@ -1745,6 +2444,8 @@ def _finish_write(self) -> dict[str, Any]:
with self._lock:
run = self._active()
assert run.browser is not None
+ run.thread.call(lambda: run.browser.settle())
+ run.thread.call(lambda: self._check_browser(run))
writes = list(run.browser.writes_sent)
shapes = list(run.browser.write_shapes)
if not writes:
@@ -1766,6 +2467,13 @@ def _finish_write(self) -> dict[str, Any]:
steps = list(run.steps)
self._end("finished")
result = verify_write(run.pending)
+ run.browser.check_guards()
+ if run.browser.blocked:
+ return {
+ "status": "not_added",
+ "what_happened": "A browser guard refused the run; no tool was added.",
+ "verification": result.to_dict(),
+ }
if not result.verified:
return {
"status": "not_added",
@@ -1802,6 +2510,9 @@ def _finish_write(self) -> dict[str, Any]:
}
folder = self.tools_dir / run.name
folder.mkdir(parents=True, exist_ok=True)
+ if run.authorization is None: # never: _start refuses a write without one
+ raise BridgeError("no teach authorization; no tool was added")
+ program = self._seal_program(program, run.authorization, folder)
(folder / PROGRAM_FILE).write_text(json.dumps(program, indent=2) + "\n")
tool = TAUGHT_PREFIX + run.name
self.programs[tool] = program
@@ -1952,6 +2663,13 @@ def _run_write(
)
name = program["result"]["contract"]
+ refused = self._write_refusal(program)
+ if refused is not None:
+ return {
+ "outcome": "did_not_run",
+ "what_happened": f"{refused}; nothing was written",
+ "model_calls": 0,
+ }
contract = self.contracts.get(name)
if contract is None:
return {
@@ -2009,7 +2727,7 @@ def _run_write(
# The steps may have sent the write before they failed: read back.
check = verify_write(pending)
return {
- "outcome": "done" if check.verified else "needs_review",
+ "outcome": "needs_review",
"what_happened": str(exc),
"verification": check.to_dict(),
"model_calls": 0,
@@ -2074,10 +2792,5 @@ def _run_taught(self, tool: str, args: Mapping[str, Any]) -> dict[str, Any]:
"model_calls": 0,
}
if (program.get("result") or {}).get("kind") == "write":
- # A write tool taught here carries no person's teach authorization.
- return {
- "outcome": "did_not_run",
- "what_happened": f"{TAUGHT_WRITE_REFUSED}; nothing was written",
- "model_calls": 0,
- }
+ return self._run_write(tool, program, inputs)
return self._replay(program, inputs)
diff --git a/packages/flow/openadapt_flow/agent/cli.py b/packages/flow/openadapt_flow/agent/cli.py
index caf1d279a4..90fae0c5c5 100644
--- a/packages/flow/openadapt_flow/agent/cli.py
+++ b/packages/flow/openadapt_flow/agent/cli.py
@@ -318,9 +318,10 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
metavar="STATEMENT",
help=(
- "With --teach: a person's signed teach authorization (openadapt-flow "
- "teach-authorization). Run 1 is scoped by it; without one, drafts "
- "are refused at admission. With --repair-access: it lets a calling "
+ "With --teach or --teach-browser: a person's signed teach "
+ "authorization (openadapt-flow teach-authorization). Run 1 is scoped "
+ "by it; without one, --teach drafts are refused at admission and "
+ "--teach-browser teaches and runs no write. With --repair-access: it lets a calling "
"agent's fix be approved (it must cover repair). Its signer is checked under the deployment config's approvers section (--config) only."
),
)
@@ -1185,6 +1186,23 @@ def _served_authority(args: argparse.Namespace) -> Any:
)
+def _config_trust(args: argparse.Namespace) -> Any:
+ """The deployment config's roster pin, for checking kept statements, or ``None``."""
+
+ approvers = _config_approvers(args)
+ if approvers is None or not approvers.key_dir:
+ return None
+ from openadapt_flow.teach_authorization import TeachAuthority
+
+ return TeachAuthority(
+ Path("."), # no statement: only the pin is used
+ key_dir=Path(approvers.key_dir),
+ approver_root=approvers.root_public_key,
+ config_only=True,
+ at_least=tuple(approvers.head) if approvers.head else None,
+ )
+
+
def _teach_authority(args: argparse.Namespace, key_dir: Optional[Path]) -> Any:
"""``--teach-authorization``, checked now so a bad one stops the server."""
@@ -1279,6 +1297,12 @@ def _serve_teach_browser(args: argparse.Namespace, serve) -> int:
headless=not args.headed,
contracts=contracts,
secrets=sign_in,
+ # What lets a write be taught: a person's signed statement,
+ # checked under the deployment config's approvers only. A bad or
+ # expired one refuses teach_start, not the server.
+ teach_authorization=_served_authority(args),
+ # What lets taught write tools run: the config's roster pin.
+ approvers=_config_trust(args),
)
except ValueError as exc:
print(f"serve: {exc}", file=sys.stderr)
diff --git a/packages/flow/openadapt_flow/agent/example_app.py b/packages/flow/openadapt_flow/agent/example_app.py
index a2c05a19db..738232d005 100644
--- a/packages/flow/openadapt_flow/agent/example_app.py
+++ b/packages/flow/openadapt_flow/agent/example_app.py
@@ -28,10 +28,17 @@
import threading
from datetime import date, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-from typing import Callable
+from typing import Any, Callable, Mapping, Optional
from urllib.parse import parse_qs, urlencode, urlsplit
-__all__ = ["COLUMNS", "ORDERS", "DEFAULT_PORT", "filter_orders", "serve"]
+__all__ = [
+ "COLUMNS",
+ "ORDERS",
+ "DEFAULT_PORT",
+ "OrderWrites",
+ "filter_orders",
+ "serve",
+]
DEFAULT_PORT = 8765
COLUMNS = ("order_id", "date", "region", "product", "units", "revenue")
@@ -246,6 +253,49 @@ def _new_order(orders: list[dict[str, str]], error: str = "") -> bytes:
ORDER_FIELDS = ("order_id", "customer", "product", "units")
+class OrderWrites:
+ """The example app's write adapter (:class:`~openadapt_flow.teach_scope.WriteAdapter`).
+
+ ``POST /orders`` with a form body is one order: the customer names the
+ record, and the product and units are its fields. Every other write is
+ unattributed, so a teach scope blocks it unless the authorization lists
+ its path (the sign-in, ``/login``).
+ """
+
+ def claim(self, method: str, url: str, body: Optional[bytes]) -> Any:
+ from openadapt_flow.teach_scope import WriteClaim
+
+ if method.upper() != "POST" or urlsplit(url).path != "/orders":
+ return None
+ try:
+ form = {
+ k: v[-1] for k, v in parse_qs((body or b"").decode("utf-8")).items()
+ }
+ except UnicodeDecodeError:
+ return None
+ # Every posted field, so an undeclared one (an order id) is refused.
+ return WriteClaim(
+ record={"customer": form.get("customer", "").strip()},
+ fields={
+ **{k: v for k, v in form.items() if k != "customer"},
+ "units": form.get("units", "").strip(),
+ },
+ )
+
+ def claim_row(self, row: Mapping[str, Any]) -> Any:
+ from openadapt_flow.teach_scope import WriteClaim
+
+ if "customer" not in row:
+ return None
+ return WriteClaim(
+ record={"customer": str(row["customer"])},
+ fields={
+ "product": str(row.get("product", "")),
+ "units": str(row.get("units", "")),
+ },
+ )
+
+
def _handler(
stats: dict[str, int],
lock: threading.Lock,
diff --git a/packages/flow/openadapt_flow/backends/redirect_guard.py b/packages/flow/openadapt_flow/backends/redirect_guard.py
new file mode 100644
index 0000000000..d9d2f3b1f9
--- /dev/null
+++ b/packages/flow/openadapt_flow/backends/redirect_guard.py
@@ -0,0 +1,146 @@
+"""Judge redirect hops that Playwright's context routes do not receive.
+
+The caller's route still judges initial requests. A separate Chromium Fetch
+session pauses subsequent hops before they leave the browser and hands each
+to that same guard. It does not replay requests or rewrite responses.
+"""
+
+from __future__ import annotations
+
+import base64
+from dataclasses import dataclass, field
+from typing import Any, Callable, Mapping, Optional
+
+
+@dataclass
+class RedirectGuard:
+ problem: Optional[str] = None
+ violations: list[str] = field(default_factory=list)
+ _session: Any = field(default=None, repr=False)
+
+ @property
+ def ok(self) -> bool:
+ return self.problem is None and not self.violations
+
+
+class _Request:
+ def __init__(self, event: Mapping[str, Any], page: Any, main_frame: str):
+ request = event["request"]
+ self.url = str(request["url"])
+ self.method = str(request["method"]).upper()
+ self.headers = {
+ str(k).lower(): str(v) for k, v in request.get("headers", {}).items()
+ }
+ # A hop is independently judged, never excused as the server's answer.
+ self.redirected_from = None
+ self.redirect_guarded = True
+ self.frame = page.main_frame if event.get("frameId") == main_frame else None
+ self._navigation = event.get("resourceType") == "Document"
+ entries = request.get("postDataEntries")
+ if entries:
+ self.post_data_buffer: Optional[bytes] = b"".join(
+ base64.b64decode(entry["bytes"], validate=True) for entry in entries
+ )
+ elif isinstance(request.get("postData"), str):
+ content_type = self.headers.get("content-type", "").split(";")[0]
+ if content_type not in (
+ "application/json",
+ "application/x-www-form-urlencoded",
+ "text/plain",
+ ):
+ raise ValueError("the redirected body cannot be read exactly")
+ self.post_data_buffer = request["postData"].encode("utf-8")
+ elif request.get("hasPostData"):
+ raise ValueError("the redirected body is unavailable")
+ else:
+ self.post_data_buffer = None
+
+ def is_navigation_request(self) -> bool:
+ return self._navigation
+
+
+class _Route:
+ def __init__(self, session: Any, request_id: str, request: _Request):
+ self.request = request
+ self._session = session
+ self._request_id = request_id
+ self.done = False
+
+ def abort(self) -> None:
+ if not self.done:
+ self._session.send(
+ "Fetch.failRequest",
+ {"requestId": self._request_id, "errorReason": "BlockedByClient"},
+ )
+ self.done = True
+
+ def continue_(self) -> None:
+ self._session.send("Fetch.continueRequest", {"requestId": self._request_id})
+ self.done = True
+
+
+def guard_redirects(
+ page: Any,
+ handler: Callable[[Any], None],
+ on_violation: Callable[[str], None],
+) -> RedirectGuard:
+ """Install before navigation; setup failure and every violation must halt."""
+
+ state = RedirectGuard()
+ try:
+ session = page.context.new_cdp_session(page)
+ state._session = session
+ main_frame = session.send("Page.getFrameTree", {})["frameTree"]["frame"]["id"]
+ seen_network: set[str] = set()
+
+ def refuse(request_id: str) -> None:
+ reason = "a redirected request could not be checked; it was blocked"
+ state.violations.append(reason)
+ on_violation(reason)
+ try:
+ session.send(
+ "Fetch.failRequest",
+ {"requestId": request_id, "errorReason": "BlockedByClient"},
+ )
+ except Exception: # noqa: BLE001 - the context must halt regardless
+ pass
+
+ def paused(event: Mapping[str, Any]) -> None:
+ request_id = str(event.get("requestId") or "")
+ route = None
+ try:
+ network_id = str(event.get("networkId") or "")
+ redirected = bool(event.get("redirectedRequestId")) or (
+ network_id in seen_network
+ )
+ if not network_id or not request_id or len(seen_network) >= 100000:
+ raise ValueError("request redirect provenance is unavailable")
+ seen_network.add(network_id)
+ if redirected:
+ route = _Route(
+ session, request_id, _Request(event, page, main_frame)
+ )
+ handler(route)
+ if not route.done:
+ raise ValueError("the redirect guard left a request undecided")
+ else:
+ # The context route judges this initial request. This
+ # session only adds the missing later-hop coverage.
+ session.send("Fetch.continueRequest", {"requestId": request_id})
+ except Exception: # noqa: BLE001 - no unchecked redirect can proceed
+ if route is None or not route.done:
+ refuse(request_id)
+
+ session.on("Fetch.requestPaused", paused)
+ session.send(
+ "Fetch.enable",
+ {
+ "patterns": [
+ {"urlPattern": "http://*", "requestStage": "Request"},
+ {"urlPattern": "https://*", "requestStage": "Request"},
+ ]
+ },
+ )
+ except Exception: # noqa: BLE001 - no redirect coverage means refusal
+ state.problem = "this browser cannot guard redirected requests"
+ return state
diff --git a/packages/flow/openadapt_flow/backends/worker_containment.py b/packages/flow/openadapt_flow/backends/worker_containment.py
new file mode 100644
index 0000000000..45f98fd11f
--- /dev/null
+++ b/packages/flow/openadapt_flow/backends/worker_containment.py
@@ -0,0 +1,174 @@
+"""Keep page JavaScript from starting workers or new windows in a guarded context.
+
+Playwright's ``context.route`` doesn't see a shared worker's requests, and
+``route_web_socket`` doesn't see a worker's WebSockets, so a worker could
+send a write that no route guard sees (OpenAdaptAI/OpenAdapt#1814). This
+module keeps workers and new windows from starting at all, and stops the run
+if one appears anyway:
+
+* Every document gets a ``worker-src 'none'`` Content Security Policy before
+ its first script runs. The browser itself then refuses dedicated, shared,
+ service, blob and module workers in every frame, and documents created
+ from it (``about:blank`` and ``srcdoc`` frames) inherit the policy. The
+ policy goes in through a `` `` element inserted and removed by an init
+ script: the response itself is not rewritten, so the document keeps its
+ network security state, and a removed policy stays in force.
+* ``window.open`` is replaced, in every document, by a non-configurable
+ function that opens nothing.
+* A new window or tab opened any other way (a link or form with a target)
+ is refused at its first request: the route aborts every request that
+ doesn't come from the guarded page, so the new window's document never
+ loads and none of its scripts run. Watching for the ``page`` event alone
+ is not enough, because a new window's head script can run before Python
+ sees the event.
+* A worker target of the guarded context that still appears (seen through
+ the browser's target discovery) is reported.
+
+Each refusal goes to ``on_violation``. The caller treats any violation as a
+halt: an unseen worker request must stop the run, never pass it.
+:func:`contain_workers` refuses (``problem`` set) when it can't install every
+layer, for example on a browser without the DevTools target domain.
+"""
+
+from __future__ import annotations
+
+import threading
+from dataclasses import dataclass, field
+from typing import Any, Callable, Optional
+
+#: Target types that run script outside a document.
+WORKER_TARGET_TYPES = frozenset({"worker", "shared_worker", "service_worker"})
+
+_INIT_JS = """(() => {
+ const report = globalThis.__openadaptWorkerViolation;
+ addEventListener('securitypolicyviolation', (event) => {
+ if (event.isTrusted && event.effectiveDirective === 'worker-src') {
+ report().catch(() => {});
+ }
+ });
+ const before = document.documentElement;
+ const make = (n) => document.createElementNS('http://www.w3.org/1999/xhtml', n);
+ try {
+ const root = before || document.appendChild(make('html'));
+ const head = document.head || root.appendChild(make('head'));
+ const meta = make('meta');
+ meta.httpEquiv = 'Content-Security-Policy';
+ meta.content = "worker-src 'none'";
+ head.append(meta);
+ // A policy stays in force after its element is removed. Removing the
+ // temporary elements lets the parser build the real document.
+ if (!before) root.remove(); else meta.remove();
+ } catch (e) {}
+ try {
+ Object.defineProperty(window, 'open', {
+ value: function open() { return null; },
+ writable: false, configurable: false, enumerable: true,
+ });
+ } catch (e) {}
+})();"""
+
+NEW_WINDOW = "the page opened a new window or tab, and it was stopped"
+WORKER_STARTED = "a worker started on the page, and its requests can't be checked"
+WORKER_REFUSED = "the page tried to start a worker, and the browser refused it"
+
+
+@dataclass
+class WorkerContainment:
+ """What :func:`contain_workers` installed, and what it has seen."""
+
+ problem: Optional[str] = None
+ violations: list[str] = field(default_factory=list)
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
+ _report: Optional[Callable[[str], None]] = field(default=None, repr=False)
+
+ def violate(self, reason: str) -> None:
+ with self._lock:
+ self.violations.append(reason)
+ if self._report is not None:
+ try:
+ self._report(reason)
+ except Exception: # noqa: BLE001 - the violation is kept here
+ pass
+
+ @property
+ def ok(self) -> bool:
+ return self.problem is None and not self.violations
+
+
+def contain_workers(
+ page: Any, on_violation: Optional[Callable[[str], None]] = None
+) -> WorkerContainment:
+ """Install worker and new-window containment on ``page``'s context.
+
+ Call it before the page loads anything: a document that loaded earlier
+ has no worker policy. Register it after the caller's own
+ ``context.route`` handlers, because routes run newest first: this one
+ aborts requests from any other page and hands the guarded page's
+ requests on with ``route.fallback()``.
+ """
+
+ state = WorkerContainment(_report=on_violation)
+ context = page.context
+ try:
+ session = context.new_cdp_session(page)
+ info = session.send("Target.getTargetInfo")
+ browser_context = info["targetInfo"].get("browserContextId")
+ except Exception: # noqa: BLE001 - no target domain: refuse
+ state.problem = "this browser can't report its workers, so writes can't be held"
+ return state
+
+ def on_target(event: dict[str, Any]) -> None:
+ target = event.get("targetInfo") or {}
+ if target.get("type") not in WORKER_TARGET_TYPES:
+ return
+ # A worker with no context named (a service worker left behind by
+ # another context, say) can't be ruled out, so it counts too.
+ if browser_context and target.get("browserContextId") not in (
+ None,
+ browser_context,
+ ):
+ return
+ state.violate(WORKER_STARTED)
+
+ def route(route: Any) -> None:
+ request = route.request
+ try:
+ owner = request.frame.page
+ except Exception: # noqa: BLE001 - no page yet, or a worker's request
+ owner = None
+ if owner is not page:
+ try:
+ navigating = owner is not None or request.is_navigation_request()
+ except Exception: # noqa: BLE001
+ navigating = False
+ state.violate(NEW_WINDOW if navigating else WORKER_STARTED)
+ route.abort()
+ return
+ route.fallback()
+
+ def on_page(other: Any) -> None:
+ if other is page:
+ return
+ state.violate(NEW_WINDOW)
+ try:
+ other.close()
+ except Exception: # noqa: BLE001 - its requests are aborted anyway
+ pass
+
+ try:
+ session.on("Target.targetCreated", on_target)
+ session.send("Target.setDiscoverTargets", {"discover": True})
+ # A worker refused by CSP never becomes a target. Report the browser's
+ # policy violation too, so callers halt on the attempt, not only on a
+ # worker that escaped the policy. The init script captures the binding
+ # before page script can replace it.
+ context.expose_binding(
+ "__openadaptWorkerViolation",
+ lambda _source: state.violate(WORKER_REFUSED),
+ )
+ context.add_init_script(_INIT_JS)
+ context.route("**/*", route)
+ context.on("page", on_page)
+ except Exception: # noqa: BLE001
+ state.problem = "worker containment could not be installed"
+ return state
diff --git a/packages/flow/openadapt_flow/jit/adapters.py b/packages/flow/openadapt_flow/jit/adapters.py
index 1cb28656c3..84772c0137 100644
--- a/packages/flow/openadapt_flow/jit/adapters.py
+++ b/packages/flow/openadapt_flow/jit/adapters.py
@@ -630,6 +630,9 @@ def execute(self, native: Mapping[str, Any]) -> Observation:
self._guard.check_ops(ops, page=self._page, pointer=self._pointer)
notes: list[str] = []
for op in ops:
+ if self._guard is not None:
+ # Where the key lands is known only now: focus may have moved.
+ self._guard.check_key(op, self._page)
note = self._perform(op)
if note:
notes.append(note)
diff --git a/packages/flow/openadapt_flow/teach_authorization.py b/packages/flow/openadapt_flow/teach_authorization.py
index d2890208d4..a7930e865a 100644
--- a/packages/flow/openadapt_flow/teach_authorization.py
+++ b/packages/flow/openadapt_flow/teach_authorization.py
@@ -909,6 +909,14 @@ def show(name: str, b: Bound) -> str:
"",
f"Statement sha256 {verification.sha256}",
]
+ if st.task.task_id.startswith("browser-tool."):
+ # A browser-teach write tool: what the person signs here goes further.
+ lines[-1:-1] = [
+ "This teaches a reusable write tool. These values bind its first run",
+ "only: until the statement expires, the agent may call the tool with",
+ "other records and values, each checked by the write contract.",
+ "",
+ ]
return "\n".join(lines)
diff --git a/packages/flow/openadapt_flow/teach_scope.py b/packages/flow/openadapt_flow/teach_scope.py
index 4dbadb45ee..c0acf52327 100644
--- a/packages/flow/openadapt_flow/teach_scope.py
+++ b/packages/flow/openadapt_flow/teach_scope.py
@@ -344,7 +344,7 @@ def for_task(
)
def request_refusal(
- self, method: str, url: str, body: Optional[bytes]
+ self, method: str, url: str, body: Optional[bytes], *, write: bool = False
) -> Optional[str]:
"""Why a browser request is outside the scope, or ``None`` if it may go.
@@ -360,7 +360,7 @@ def request_refusal(
return "egress_origin_not_declared"
if method == "DELETE" and "delete_request" in self.forbidden_actions:
return "forbidden_action"
- if method not in WRITE_METHODS or self.write_adapter is None:
+ if (method not in WRITE_METHODS and not write) or self.write_adapter is None:
return None
try:
claim = self.write_adapter.claim(method, url, body)
@@ -380,6 +380,14 @@ def claim_refusal(self, claim: "WriteClaim", *, write: bool) -> Optional[str]:
for name, value in self.record.items():
if name not in named or not _same(named[name], value):
return f"{prefix}_wrong_record"
+ for name, value in claim.record.items():
+ # The adapter's record key must be one the declaration names, as
+ # identity or as a field, with that value: a declaration that put
+ # it under fields still pins it.
+ if name in self.record:
+ continue
+ if name not in self.fields or not _same(value, self.fields[name]):
+ return f"{prefix}_wrong_record"
for name, value in claim.fields.items():
if name in self.record:
continue
@@ -509,29 +517,277 @@ def to_dict(self) -> dict[str, Any]:
}"""
-_CONTROL_LABEL = """([x, y]) => {
- const el = document.elementFromPoint(x, y);
- if (!el) return null;
- const c = el.closest('button, a, input, select, [role], label, summary') || el;
- const text = c.getAttribute('aria-label') || c.getAttribute('title') ||
- (c.tagName === 'INPUT' ? (c.value || c.getAttribute('placeholder') || '') : '') ||
- c.innerText || c.textContent || '';
- return text.trim().slice(0, 300);
+#: Accessibility roles that are a control a person activates.
+_CONTROL_ROLES = frozenset(
+ {
+ "button",
+ "link",
+ "menuitem",
+ "menuitemcheckbox",
+ "menuitemradio",
+ "checkbox",
+ "radio",
+ "switch",
+ "tab",
+ "option",
+ "combobox",
+ "listbox",
+ "textbox",
+ "searchbox",
+ "spinbutton",
+ "slider",
+ "treeitem",
+ "PopUpButton",
+ "ToggleButton",
+ }
+)
+#: Roles whose focus hides what is focused inside them.
+_OPAQUE_ROLES = frozenset({"Iframe", "IframePresentational", "PluginObject"})
+
+
+def _ax_value(node: Mapping[str, Any], key: str) -> str:
+ return str(((node.get(key) or {}).get("value")) or "")
+
+
+def _control_name(nodes: Sequence[Mapping[str, Any]], start: Mapping[str, Any]) -> str:
+ """The name of ``start`` or the nearest control above it, from an AX tree."""
+
+ by_id = {str(n.get("nodeId")): n for n in nodes}
+ node: Optional[Mapping[str, Any]] = start
+ seen = 0
+ while node is not None and seen < 64:
+ if _ax_value(node, "role") in _CONTROL_ROLES:
+ return _ax_value(node, "name").strip()[:300]
+ node = by_id.get(str(node.get("parentId")))
+ seen += 1
+ return _ax_value(start, "name").strip()[:300]
+
+
+def _cdp(page: Any) -> Any:
+ """A Chrome DevTools session on ``page``, or ``None`` (not Chromium)."""
+
+ try:
+ return page.context.new_cdp_session(page)
+ except Exception: # noqa: BLE001 - no CDP: the label is unknown
+ return None
+
+
+#: Text a click at a point could be said to hit: the element and three
+#: ancestors (a ```` has no accessible role). Run in an
+#: isolated world, so the page's own prototypes don't apply.
+_TEXTS_JS = """function () {
+ const out = [];
+ let e = this.nodeType === 1 ? this : this.parentElement;
+ for (let i = 0; i < 4 && e; i++, e = e.parentElement) {
+ out.push(String(e.innerText || e.textContent || '').trim().slice(0, 300));
+ }
+ return out;
+}"""
+#: The control a ``
`` (the element or an ancestor) is for.
+_LABEL_CONTROL_JS = """function () {
+ let e = this.nodeType === 1 ? this : this.parentElement;
+ for (let i = 0; i < 4 && e; i++, e = e.parentElement) {
+ if (e.tagName === 'LABEL' && e.control) return e.control;
+ }
+ return null;
+}"""
+#: The button Enter in this field submits the form with (HTML implicit
+#: submission), by the browser's own rule (``:default``).
+_DEFAULT_BUTTON_JS = """function () {
+ if (this.tagName !== 'INPUT') return null;
+ if (['button', 'submit', 'reset', 'image'].includes(this.type)) return null;
+ const f = this.form;
+ if (!f) return null;
+ return Array.from(f.elements).find((e) => e.matches(':default')) || null;
}"""
-def control_label_at(page: Any, x: int, y: int) -> Optional[str]:
- """The accessible label of the control under ``(x, y)``.
+def _isolated(
+ session: Any, frame_id: Any, backend: Any, js: str, *, by_value: bool
+) -> Any:
+ """Call ``js`` on a node in an isolated world of its frame."""
+
+ params: dict[str, Any] = {"worldName": "openadapt-teach-guard"}
+ if frame_id:
+ params["frameId"] = frame_id
+ else:
+ params["frameId"] = session.send("Page.getFrameTree", {})["frameTree"]["frame"][
+ "id"
+ ]
+ context = session.send("Page.createIsolatedWorld", params)["executionContextId"]
+ obj = session.send(
+ "DOM.resolveNode", {"backendNodeId": backend, "executionContextId": context}
+ )["object"]["objectId"]
+ result = session.send(
+ "Runtime.callFunctionOn",
+ {"functionDeclaration": js, "objectId": obj, "returnByValue": by_value},
+ )
+ if result.get("exceptionDetails"):
+ raise RuntimeError("the isolated probe failed")
+ return result["result"]
+
+
+def _ax_name_of(session: Any, backend: Any) -> Optional[str]:
+ tree = session.send(
+ "Accessibility.getPartialAXTree",
+ {"backendNodeId": backend, "fetchRelatives": True},
+ )
+ nodes = tree.get("nodes") or []
+ node = next((n for n in nodes if n.get("backendDOMNodeId") == backend), None)
+ if node is None or _ax_value(node, "role") in _OPAQUE_ROLES:
+ return None
+ return _control_name(nodes, node)
+
+
+def _object_name(session: Any, result: Mapping[str, Any]) -> Optional[str]:
+ """The accessible name of the element a probe returned, ``""`` for none."""
- ``""`` when there is no control or it has no label; ``None`` when the
- probe failed (the page broke or overrode it), which the guard refuses.
+ if result.get("subtype") == "null" or not result.get("objectId"):
+ return ""
+ node = session.send("DOM.describeNode", {"objectId": result["objectId"]})["node"]
+ return _ax_name_of(session, node["backendNodeId"])
+
+
+def _node_labels(session: Any, backend: Any, frame_id: Any) -> Optional[list[str]]:
+ """The same isolated names for a pointer target or a focused element."""
+
+ name = _ax_name_of(session, backend)
+ if name is None:
+ return None
+ texts = _isolated(session, frame_id, backend, _TEXTS_JS, by_value=True)
+ control = _isolated(session, frame_id, backend, _LABEL_CONTROL_JS, by_value=False)
+ labelled = _object_name(session, control)
+ if labelled is None:
+ return None
+ return [name, labelled, *[str(t) for t in texts.get("value") or []]]
+
+
+def control_labels_at(page: Any, x: int, y: int) -> Optional[list[str]]:
+ """Every name the control under ``(x, y)`` goes by, or ``None``.
+
+ Read over DevTools, not by a script in the page: the accessible name of
+ the control (from Chromium's accessibility tree); the text of the hit
+ element and three ancestors (a control with no role, such as a
+ ````), and, for a ``
``, the name of the control it
+ is for, both from an isolated world the page's scripts can't reach.
+ ``None`` when any of it can't be read, which the guard refuses.
"""
+ session = _cdp(page)
+ if session is None:
+ return None
try:
- label = page.evaluate(_CONTROL_LABEL, [int(x), int(y)])
+ session.send("DOM.getDocument", {"depth": 0})
+ # The point is in the viewport; DevTools locates in the document.
+ view = session.send("Page.getLayoutMetrics", {})["cssVisualViewport"]
+ found = session.send(
+ "DOM.getNodeForLocation",
+ {
+ "x": int(x + view.get("pageX", 0)),
+ "y": int(y + view.get("pageY", 0)),
+ "ignorePointerEventsNone": True,
+ },
+ )
+ return _node_labels(session, found["backendNodeId"], found.get("frameId"))
except Exception: # noqa: BLE001 - the probe failed: the label is unknown
return None
- return str(label) if label is not None else ""
+ finally:
+ try:
+ session.detach()
+ except Exception: # noqa: BLE001 - already gone
+ pass
+
+
+def control_label_at(page: Any, x: int, y: int) -> Optional[str]:
+ """The accessible name of the control under ``(x, y)``, or ``None``."""
+
+ labels = control_labels_at(page, x, y)
+ return None if labels is None else labels[0]
+
+
+def focused_control_label(page: Any) -> Optional[str]:
+ """The accessible name of the control that has focus now.
+
+ From Chromium's accessibility tree over DevTools, like
+ :func:`control_label_at`, in every frame of the page. ``""`` when
+ nothing but a document has focus; ``None`` when it can't be read, or
+ focus is in a frame the tree can't see into.
+ """
+
+ labels = key_target_labels(page, enter=False)
+ return None if labels is None else labels[0]
+
+
+_ENTER_KEYS = frozenset({"enter", "return", "numpadenter", "kp_enter"})
+
+
+def key_target_labels(page: Any, *, enter: bool) -> Optional[list[str]]:
+ """The names of every control a key could activate now, or ``None``.
+
+ The control with focus, found across the page's frames in Chromium's
+ accessibility tree; and, for ``enter``, the default button of the form
+ the focused field is in (HTML implicit submission sends the form with
+ it). ``None`` when any of it can't be read, so the guard refuses.
+ """
+
+ session = _cdp(page)
+ if session is None:
+ return None
+ try:
+ tree = session.send("Page.getFrameTree", {})["frameTree"]
+ frames: list[str] = []
+ queue = [tree]
+ while queue:
+ item = queue.pop(0)
+ frames.append(str(item["frame"]["id"]))
+ queue.extend(item.get("childFrames") or [])
+ found: Optional[tuple[list[Any], Mapping[str, Any], str]] = None
+ for frame_id in frames:
+ nodes = session.send("Accessibility.getFullAXTree", {"frameId": frame_id})[
+ "nodes"
+ ]
+ for node in nodes:
+ focused = any(
+ prop.get("name") == "focused"
+ and (prop.get("value") or {}).get("value") is True
+ for prop in node.get("properties") or []
+ )
+ if focused and _ax_value(node, "role") != "RootWebArea":
+ # The last frame's wins: the deepest.
+ found = (nodes, node, frame_id)
+ if found is None:
+ return [""]
+ nodes, node, frame_id = found
+ if _ax_value(node, "role") in _OPAQUE_ROLES:
+ return None # focus is in a frame this session can't read
+ backend = node.get("backendDOMNodeId")
+ if backend is None:
+ return None
+ labels = _node_labels(session, backend, frame_id)
+ if labels is None:
+ return None
+ if enter:
+ default = _isolated(
+ session, frame_id, backend, _DEFAULT_BUTTON_JS, by_value=False
+ )
+ if default.get("subtype") != "null" and default.get("objectId"):
+ default_node = session.send(
+ "DOM.describeNode", {"objectId": default["objectId"]}
+ )["node"]
+ default_labels = _node_labels(
+ session, default_node["backendNodeId"], frame_id
+ )
+ if default_labels is None:
+ return None
+ labels.extend(default_labels)
+ return labels
+ except Exception: # noqa: BLE001 - the probe failed: unknown, refused
+ return None
+ finally:
+ try:
+ session.detach()
+ except Exception: # noqa: BLE001 - already gone
+ pass
def link_target_at(page: Any, x: int, y: int) -> Optional[dict[str, str]]:
@@ -635,6 +891,50 @@ def _claim_refusal(self, claim: WriteClaim, *, write: bool) -> Optional[str]:
# -- actions ------------------------------------------------------------------
+ def check_key(self, op: Any, page: Any = None) -> None:
+ """Refuse a key (or typing) that arrives at a forbidden control.
+
+ Called just before each key op is performed, so it reads the control
+ that has focus when the key arrives (reached by Tab, a click, or the
+ page itself), from the browser's accessibility tree. Any key counts:
+ Enter, Space and others can activate a control. Raises
+ :class:`ScopeViolation`.
+ """
+
+ if not self.scope.forbidden_controls or op.kind not in (
+ "press",
+ "hotkey",
+ "type",
+ ):
+ return
+ if op.kind == "hotkey" and op.modifiers:
+ # A modifier chord can activate a control by its access key,
+ # wherever focus is.
+ self._raise(
+ "control_forbidden",
+ "key combinations are refused while the authorization forbids "
+ "some controls",
+ )
+ key = (op.key or "").lower()
+ enter = key in _ENTER_KEYS or (
+ op.kind == "type" and ("\n" in (op.text or "") or "\r" in (op.text or ""))
+ )
+ labels = key_target_labels(page, enter=enter) if page is not None else None
+ if labels is None:
+ self._raise(
+ "control_forbidden",
+ "the control the key would reach could not be read, and the "
+ "authorization forbids some controls",
+ )
+ if any(
+ label and normalize_structured(label) in self.scope.forbidden_controls
+ for label in labels or []
+ ):
+ self._raise(
+ "control_forbidden",
+ "the key would reach a control the authorization forbids",
+ )
+
def check_ops(
self,
ops: Sequence[Op],
@@ -652,14 +952,24 @@ def check_ops(
"forbidden_action",
f"the authorization forbids {op.kind.replace('_', ' ')}",
)
+ if (
+ op.kind in ("click", "double_click", "right_click")
+ and page is None
+ and self.scope.forbidden_controls
+ ):
+ self._raise(
+ "control_forbidden",
+ "there is no page to read the clicked control from, and the "
+ "authorization forbids some controls",
+ )
if (
op.kind in ("click", "double_click", "right_click")
and page is not None
and self.scope.forbidden_controls
):
at = (op.x, op.y) if op.x is not None and op.y is not None else pointer
- label = control_label_at(page, at[0], at[1]) if at else ""
- if label is None:
+ names = control_labels_at(page, at[0], at[1]) if at else None
+ if names is None:
# Fail closed: a page that breaks the probe could hide a
# forbidden control behind it.
self._raise(
@@ -667,8 +977,9 @@ def check_ops(
"the clicked control's label could not be read, and the "
"authorization forbids some controls",
)
- if label and (
- normalize_structured(label) in self.scope.forbidden_controls
+ if any(
+ n and normalize_structured(n) in self.scope.forbidden_controls
+ for n in names or []
):
self._raise(
"control_forbidden",
diff --git a/packages/flow/openadapt_flow/wire_check.py b/packages/flow/openadapt_flow/wire_check.py
index 6c9a491107..c536145882 100644
--- a/packages/flow/openadapt_flow/wire_check.py
+++ b/packages/flow/openadapt_flow/wire_check.py
@@ -173,6 +173,13 @@ def body_shape(request: Any) -> str:
same save with new values has the same shape."""
names: list[str] = []
+ if request.method.upper() in READ_METHODS:
+ # Explicitly declared GET writes carry their fields in the query.
+ names = [
+ "?" + key
+ for key, _ in parse_qsl(urlsplit(request.url).query, keep_blank_values=True)
+ ]
+ return hashlib.sha256("\n".join(sorted(set(names))).encode()).hexdigest()[:16]
fields = body_fields(request)
if fields is not None:
names = [name for name, _ in fields]
@@ -236,6 +243,15 @@ class WireCheck:
shapes: list[str] = field(default_factory=list)
_submitted: bool = False
+ def is_write(self, request: Any) -> bool:
+ """A non-read method or a read method the contract declares as a write."""
+
+ method = request.method.upper()
+ write = f"{method} {urlsplit(request.url).path or '/'}"
+ return method not in READ_METHODS or (
+ self.allowed is not None and write in self.allowed
+ )
+
def judge(self, request: Any) -> Optional[str]:
"""``None`` when ``request`` may go; else why it must not."""
@@ -248,7 +264,7 @@ def judge(self, request: Any) -> Optional[str]:
address = f"{parts.scheme}://{parts.netloc}{parts.path}"
origin = f"{parts.scheme}://{parts.netloc}".lower()
sub = self.submission
- is_write = method not in READ_METHODS
+ is_write = self.is_write(request)
submission_get = (
sub is not None
and method == "GET"
@@ -337,7 +353,7 @@ def handle(
return
method = request.method.upper()
if request.redirected_from is None and (
- method not in READ_METHODS
+ self.is_write(request)
or (
self.submission is not None
and request.is_navigation_request()
diff --git a/packages/flow/tests/_teach_authorization_support.py b/packages/flow/tests/_teach_authorization_support.py
index d5288002a2..e122a2ccc2 100644
--- a/packages/flow/tests/_teach_authorization_support.py
+++ b/packages/flow/tests/_teach_authorization_support.py
@@ -201,3 +201,65 @@ def repair_authorization(
found = authorizer(key_dir)
verification = ta.verify(statement, key_dir=key_dir, root=found.root)
return RepairStore(store_root).keep_teach_authorization(verification, statement)
+
+
+ORDER_ADAPTER = "openadapt_flow.agent.example_app:OrderWrites"
+
+
+def order_spec(
+ base_url: str,
+ values: Mapping[str, str],
+ *,
+ name: str = "add_order",
+ identity: Sequence[str] = ("customer",),
+ record_kind: str = "order",
+ write_adapter: Optional[str] = ORDER_ADAPTER,
+ expected_rows: int = 1,
+ forbidden_controls: Sequence[str] = (),
+) -> dict[str, Any]:
+ """What a person signs to let an agent teach the order write ``name``."""
+
+ from openadapt_flow.teach_scope import origin_of
+
+ writes: dict[str, Any] = {
+ "record_kind": record_kind,
+ "identity": {k: {"value": values[k]} for k in identity},
+ "fields": {k: {"value": v} for k, v in values.items() if k not in identity},
+ "allowed_write_paths": ["/login"],
+ "expected_rows": expected_rows,
+ }
+ if write_adapter is not None:
+ writes["write_adapter"] = write_adapter
+ return {
+ "task": {"task_id": f"browser-tool.{name}", "summary": "Add one order"},
+ "purposes": ["teach"],
+ "origins": [str(origin_of(base_url))],
+ "inputs": {k: {"value": v} for k, v in values.items()},
+ "writes": writes,
+ "forbidden": {"controls": list(forbidden_controls)},
+ }
+
+
+def order_authority(
+ folder: Path,
+ base_url: str,
+ values: Mapping[str, str],
+ *,
+ signer: Optional[LocalAuthorizer] = None,
+ **kw: Any,
+) -> ta.TeachAuthority:
+ """A real approver on a real roster signs :func:`order_spec`; pins it."""
+
+ folder = Path(folder)
+ person = signer or LocalAuthorizer(folder / "approvers", "clinic-lead")
+ admission_key(person.key_dir)
+ statement = person.issue(folder / "statement", order_spec(base_url, values, **kw))
+ return person.authority(statement)
+
+
+def admission_key(key_dir: Path) -> None:
+ """The local admission key an operator sets up in the key directory."""
+
+ from openadapt_flow.execute.keys import load_or_create_private_key
+
+ load_or_create_private_key(Path(key_dir))
diff --git a/packages/flow/tests/e2e/test_browser_teach_write_e2e.py b/packages/flow/tests/e2e/test_browser_teach_write_e2e.py
index bc99ea108d..42a6e57a0e 100644
--- a/packages/flow/tests/e2e/test_browser_teach_write_e2e.py
+++ b/packages/flow/tests/e2e/test_browser_teach_write_e2e.py
@@ -1,25 +1,58 @@
-"""Taught browser writes are refused.
+"""Teach a write once, then call it as a tool that returns a verified receipt.
-Browser teach refuses taught writes until a person's signed teach
-authorization is bound to them (docs/TEACH_AUTHORIZATION.md). Against the
-real example app: ``teach_start`` with a write contract is refused before a
-browser opens, and a write tool already on disk doesn't run. Nothing is
-written to the app.
+Everything is real except the agent: the MCP SDK client talks to the real
+server, which drives headless Chromium against the example app on
+127.0.0.1. Run 1 signs in with values the server holds, fills a New Order
+form and saves it. The write contract reads the app's order store
+(``GET /api/orders``), so every result comes from the store, not the screen.
+The agent is scripted: it picks refs from the listings by label. No model is
+called.
+
+It asserts:
+
+* teach_finish verifies run 1's order in the store before it adds the tool;
+* the taught tool, called with a new order, saves it and returns a verified
+ receipt;
+* an app that answers "saved" but keeps nothing, or keeps the order twice,
+ gets a refusal, never done;
+* the sign-in values never appear in a page listing, a result or the program.
"""
from __future__ import annotations
import json
+import re
import urllib.request
from pathlib import Path
-from typing import Any, Iterator
+from typing import Any, Iterator, Optional
+import anyio
import pytest
-from openadapt_flow.agent import browser_teach as bt
-from openadapt_flow.agent.bridge import BridgeError
-from openadapt_flow.agent.example_app import serve
-from openadapt_flow.verify import WriteContract
+pytest.importorskip("mcp")
+pytest.importorskip("playwright")
+
+from mcp import ClientSession # noqa: E402
+from mcp.shared.memory import create_client_server_memory_streams # noqa: E402
+
+from openadapt_flow.agent.browser_teach import BrowserTeachBridge # noqa: E402
+from openadapt_flow.agent.example_app import ( # noqa: E402
+ DEMO_PASSWORD,
+ DEMO_USER,
+ serve,
+)
+from openadapt_flow.agent.mcp import build_server # noqa: E402
+from openadapt_flow.verify import WriteContract # noqa: E402
+from tests._teach_authorization_support import order_authority # noqa: E402
+
+FIRST = {"customer": "Acme Ltd", "product": "Desk lamp", "units": "3"}
+SECOND = {"customer": "Birch Co", "product": "Office chair", "units": "5"}
+
+
+@pytest.fixture(autouse=True)
+def _seal_home(tmp_path, monkeypatch):
+ # Receipts are sealed; keep the test's Seal key out of the home folder.
+ monkeypatch.setenv("OPENADAPT_SEAL_HOME", str(tmp_path / "seal"))
@pytest.fixture
@@ -29,15 +62,20 @@ def app() -> Iterator[str]:
stop()
-def _orders(base: str) -> Any:
- with urllib.request.urlopen(f"{base}/api/orders", timeout=5) as response:
- return json.loads(response.read())["records"]
+def _get(url: str) -> Any:
+ with urllib.request.urlopen(url, timeout=5) as response:
+ body = response.read()
+ try:
+ return json.loads(body)
+ except ValueError:
+ return body.decode()
def _contract(base: str) -> WriteContract:
return WriteContract.from_dict(
{
"schema_version": "openadapt.verify/v1",
+ # The requests a taught order may send: the sign-in and the save.
"writes": ["POST /login", "POST /orders"],
"verifier": {
"kind": "rest",
@@ -48,7 +86,11 @@ def _contract(base: str) -> WriteContract:
"effects": [
{
"kind": "record_written",
- "match": {"customer": {"param": "customer"}},
+ "match": {
+ "customer": {"param": "customer"},
+ "product": {"param": "product"},
+ "units": {"param": "units"},
+ },
"expected_count": 1,
"count_new_only": True,
"timeout_s": 1.0,
@@ -59,41 +101,178 @@ def _contract(base: str) -> WriteContract:
)
-def test_a_taught_write_is_refused_and_writes_nothing(app: str, tmp_path: Path) -> None:
- before = _orders(app)
- bridge = bt.BrowserTeachBridge(
- tmp_path / "tools", tmp_path / "files", contracts={"order": _contract(app)}
+def _ref(listing: str, label: str) -> str:
+ for line in listing.splitlines():
+ found = re.match(r"\[(e\d+)\] \S+ \"" + re.escape(label) + '"', line)
+ if found:
+ return found.group(1)
+ raise AssertionError(f"no {label!r} in listing:\n{listing}")
+
+
+async def _session(server: Any, steps: Any) -> Any:
+ async with create_client_server_memory_streams() as (client, transport):
+ async with anyio.create_task_group() as tasks:
+ tasks.start_soon(
+ server.run, *transport, server.create_initialization_options()
+ )
+ async with ClientSession(*client) as session:
+ await session.initialize()
+
+ async def call(name: str, args: Optional[dict] = None) -> dict:
+ result = await session.call_tool(name, args or {})
+ text = result.content[0].text
+ payload = json.loads(text)
+ if getattr(result, "isError", None) or getattr(
+ result, "is_error", None
+ ):
+ return {"_error": payload.get("error"), "_text": text}
+ payload["_text"] = text
+ return payload
+
+ out = await steps(call)
+ tasks.cancel_scope.cancel()
+ return out
+
+
+def test_teach_a_write_then_call_it_verified(
+ app: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setenv("PORTAL_USER", DEMO_USER)
+ monkeypatch.setenv("PORTAL_PASSWORD", DEMO_PASSWORD)
+ bridge = BrowserTeachBridge(
+ tmp_path / "tools",
+ tmp_path / "files",
+ contracts={"order": _contract(app)},
+ secrets={"username": "PORTAL_USER", "password": "PORTAL_PASSWORD"},
+ verify_dir=tmp_path / "verify",
+ # A person signed that an agent may teach exactly this order write.
+ teach_authorization=order_authority(tmp_path / "authorization", app, FIRST),
)
- with pytest.raises(BridgeError, match="taught writes are refused"):
- bridge.dispatch(
+ server = build_server(teach=bridge)
+ seen: list[str] = []
+
+ async def steps(call):
+ out: dict[str, Any] = {}
+ started = await call(
"teach_start",
{
"name": "add_order",
"url": f"{app}/login",
- "task": "Add an order",
+ "task": "Add an order for a customer",
"contract": "order",
- "inputs": {"customer": "Acme Ltd"},
+ "inputs": FIRST,
+ },
+ )
+ assert started.get("status") == "recording", started
+ assert started["sign_in_inputs"] == ["password", "username"]
+ page = started["page"]
+ seen.append(started["_text"])
+
+ async def act(**args: Any) -> None:
+ nonlocal page
+ result = await call("teach_act", args)
+ assert "_error" not in result, (args, result)
+ seen.append(result["_text"])
+ page = result["page"]
+
+ await act(action="type", ref=_ref(page, "Username"), input="username")
+ await act(action="type", ref=_ref(page, "Password"), input="password")
+ await act(action="click", ref=_ref(page, "Sign in"))
+ await act(action="type", ref=_ref(page, "Customer"), input="customer")
+ await act(action="select", ref=_ref(page, "Product"), input="product")
+ await act(action="type", ref=_ref(page, "Units"), input="units")
+ await act(action="click", ref=_ref(page, "Save order"))
+ out["finished"] = await call("teach_finish", {})
+ seen.append(out["finished"]["_text"])
+
+ out["second"] = await call("taught_add_order", {"inputs": SECOND})
+ _get(f"{app}/_fault?mode=no_persist")
+ out["dropped"] = await call(
+ "taught_add_order",
+ {
+ "inputs": {
+ "customer": "Cedar Inc",
+ "product": "USB-C dock",
+ "units": "2",
+ }
},
)
- folder = tmp_path / "tools" / "add_order"
- folder.mkdir(parents=True)
- (folder / bt.PROGRAM_FILE).write_text(
- json.dumps(
+ _get(f"{app}/_fault?mode=twice")
+ out["doubled"] = await call(
+ "taught_add_order",
+ {
+ "inputs": {
+ "customer": "Dune LLC",
+ "product": "Monitor arm",
+ "units": "4",
+ }
+ },
+ )
+ _get(f"{app}/_fault?mode=")
+ for key in ("second", "dropped", "doubled"):
+ seen.append(out[key]["_text"])
+ return out
+
+ out = anyio.run(_session, server, steps)
+
+ finished = out["finished"]
+ assert finished["status"] == "ready", finished
+ assert finished["new_tool"] == "taught_add_order"
+ assert finished["run_1"]["verified"] is True
+ assert finished["writes_sent"] == ["POST /login", "POST /orders"]
+
+ second = out["second"]
+ assert second["outcome"] == "done", second
+ assert second["verified"] is True and len(second["receipt_sha256"]) == 64
+ assert second["model_calls"] == 0
+
+ dropped = out["dropped"]
+ assert dropped["outcome"] == "needs_review", dropped
+ assert dropped["verified"] is False and dropped["retry_safe"] is False
+ assert dropped["verdict"] == "RECONCILIATION_REQUIRED"
+
+ doubled = out["doubled"]
+ assert doubled["outcome"] == "needs_review", doubled
+ assert doubled["verdict"] == "REFUTED"
+
+ records = _get(f"{app}/api/orders")["records"]
+ assert [r["customer"] for r in records] == [
+ "Acme Ltd",
+ "Birch Co",
+ "Dune LLC",
+ "Dune LLC",
+ ]
+
+ # The sign-in values reach no listing, result or program.
+ program = (tmp_path / "tools" / "add_order" / "program.json").read_text()
+ for text in [*seen, program]:
+ assert DEMO_PASSWORD not in text
+ assert DEMO_USER not in text
+ assert json.loads(program)["sign_in"] == ["password", "username"]
+
+
+def test_a_taught_write_without_its_sign_in_does_not_run(
+ app: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.delenv("PORTAL_PASSWORD", raising=False)
+ monkeypatch.setenv("PORTAL_USER", DEMO_USER)
+ bridge = BrowserTeachBridge(
+ tmp_path / "tools",
+ tmp_path / "files",
+ contracts={"order": _contract(app)},
+ secrets={"username": "PORTAL_USER", "password": "PORTAL_PASSWORD"},
+ verify_dir=tmp_path / "verify",
+ # A person signed that an agent may teach exactly this order write.
+ teach_authorization=order_authority(tmp_path / "authorization", app, FIRST),
+ )
+ with pytest.raises(Exception, match="PORTAL_PASSWORD"):
+ bridge.dispatch(
+ "teach_start",
{
- "schema": bt.PROGRAM_SCHEMA,
"name": "add_order",
- "task": "Add an order",
"url": f"{app}/login",
- "inputs": ["customer"],
- "steps": [],
- "writes": [],
- "result": {"kind": "write", "contract": "order"},
- }
+ "task": "Add an order",
+ "contract": "order",
+ "inputs": FIRST,
+ },
)
- )
- bridge = bt.BrowserTeachBridge(
- tmp_path / "tools", tmp_path / "files", contracts={"order": _contract(app)}
- )
- result = bridge.dispatch("taught_add_order", {"inputs": {"customer": "Birch"}})
- assert result["outcome"] == "did_not_run", result
- assert _orders(app) == before
diff --git a/packages/flow/tests/e2e/test_browser_teach_write_guard_e2e.py b/packages/flow/tests/e2e/test_browser_teach_write_guard_e2e.py
index f5250c690e..2dc9c5d7f9 100644
--- a/packages/flow/tests/e2e/test_browser_teach_write_guard_e2e.py
+++ b/packages/flow/tests/e2e/test_browser_teach_write_guard_e2e.py
@@ -1,37 +1,145 @@
-"""A taught write sends nothing while taught writes are refused.
+"""A taught write sends only its taught requests, and sign-in values stay put.
-Browser teach refuses taught writes until a person's signed teach
-authorization is bound to them (docs/TEACH_AUTHORIZATION.md). Against the
-real example app: ``teach_start`` with a write contract is refused before a
-browser opens, and a write tool already on disk doesn't run. Nothing is
-written to the app.
+From the #1589 review, in real headless Chromium against a small app with a
+sign-in, a GET search box, a POST order form and a POST "Close account"
+button. The server counts what it receives.
+
+* Teaching: a write the contract doesn't list (Close account) is stopped
+ before it is sent, and run 1 ends.
+* Teaching: a sign-in value can't be typed into the search box (it would go
+ into an address) or into the order form, and typed as plain text it isn't
+ a value the person's teach authorization allows.
+* Calling the tool: a page that adds a field to the order, or a second write,
+ is stopped before it is sent, and the result is never done.
+* No form of the password (raw, percent, form, JSON, base64) appears in any
+ result. The password has @, a space and &.
+* A verified result carries a Seal of its receipt and says the values were
+ declared by the agent.
"""
from __future__ import annotations
+import http.server
import json
-import urllib.request
+import re
+import threading
+import urllib.parse
from pathlib import Path
-from typing import Any, Iterator
+from typing import Iterator
import pytest
-from openadapt_flow.agent import browser_teach as bt
-from openadapt_flow.agent.bridge import BridgeError
-from openadapt_flow.agent.example_app import serve
-from openadapt_flow.verify import WriteContract
+pytest.importorskip("playwright")
+from openadapt_flow.agent.bridge import BridgeError # noqa: E402
+from openadapt_flow.agent.browser_teach import BrowserTeachBridge # noqa: E402
+from openadapt_flow.verify import WriteContract # noqa: E402
+from openadapt_flow.wire_check import secret_forms # noqa: E402
+from tests._teach_authorization_support import order_authority # noqa: E402
-@pytest.fixture
-def app() -> Iterator[str]:
- base, stop = serve(0)
- yield base
- stop()
+PASSWORD = "p@ss w0rd&x"
+
+
+class _App:
+ def __init__(self) -> None:
+ self.orders: list[dict[str, str]] = []
+ self.closed = 0
+ self.mode = ""
+ app = self
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *_a: object) -> None:
+ pass
+
+ def _send(
+ self, body: bytes, ctype: str = "text/html", code: int = 200
+ ) -> None:
+ self.send_response(code)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _page(self, title: str, body: str) -> None:
+ extra = ""
+ if app.mode == "extra_field":
+ extra = """"""
+ if app.mode == "socket":
+ # Page script opens a WebSocket to the app.
+ extra = """"""
+ if app.mode == "tall":
+ extra = ""
+ if app.mode == "rewrite":
+ # Page script swaps the customer as the form is sent.
+ extra = """"""
+ if app.mode == "second_write":
+ extra = """"""
+ self._send(
+ f"{title} {body}{extra}".encode()
+ )
+ def do_GET(self) -> None: # noqa: N802
+ path = urllib.parse.urlsplit(self.path).path
+ if path == "/login":
+ self._page(
+ "Sign in",
+ '",
+ )
+ elif path in ("/app", "/search"):
+ self._page(
+ "App",
+ '"
+ ''
+ '",
+ )
+ elif path == "/api/orders":
+ self._send(
+ json.dumps({"records": app.orders}).encode(), "application/json"
+ )
+ else:
+ self._send(b"nf", code=404)
-def _orders(base: str) -> Any:
- with urllib.request.urlopen(f"{base}/api/orders", timeout=5) as response:
- return json.loads(response.read())["records"]
+ def do_POST(self) -> None: # noqa: N802
+ length = int(self.headers.get("Content-Length") or 0)
+ raw = self.rfile.read(length).decode()
+ if "json" in (self.headers.get("Content-Type") or ""):
+ form = json.loads(raw)
+ else:
+ form = dict(urllib.parse.parse_qsl(raw))
+ if self.path == "/orders":
+ app.orders.append({"customer": form.get("customer", "")})
+ elif self.path == "/account/close":
+ app.closed += 1
+ self.send_response(303)
+ self.send_header("Location", "/app")
+ self.end_headers()
+
+ self.http = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ threading.Thread(target=self.http.serve_forever, daemon=True).start()
+ self.base = f"http://127.0.0.1:{self.http.server_address[1]}"
+
+
+@pytest.fixture
+def app() -> Iterator[_App]:
+ server = _App()
+ yield server
+ server.http.shutdown()
def _contract(base: str) -> WriteContract:
@@ -59,41 +167,246 @@ def _contract(base: str) -> WriteContract:
)
-def test_no_taught_write_reaches_the_app(app: str, tmp_path: Path) -> None:
- before = _orders(app)
- bridge = bt.BrowserTeachBridge(
- tmp_path / "tools", tmp_path / "files", contracts={"order": _contract(app)}
+def _ref(listing: str, label: str) -> str:
+ for line in listing.splitlines():
+ found = re.match(r"\[(e\d+)\] \S+ \"" + re.escape(label) + '"', line)
+ if found:
+ return found.group(1)
+ raise AssertionError(f"no {label!r}:\n{listing}")
+
+
+@pytest.fixture
+def bridge(
+ app: _App, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> BrowserTeachBridge:
+ monkeypatch.setenv("P_USER", "clerk")
+ monkeypatch.setenv("P_PW", PASSWORD)
+ monkeypatch.setenv("OPENADAPT_SEAL_HOME", str(tmp_path / "seal"))
+ return BrowserTeachBridge(
+ tmp_path / "tools",
+ tmp_path / "files",
+ contracts={"order": _contract(app.base)},
+ secrets={"username": "P_USER", "password": "P_PW"},
+ verify_dir=tmp_path / "verify",
+ # A person signed that an agent may teach an order for Acme.
+ teach_authorization=order_authority(
+ tmp_path / "authorization", app.base, {"customer": "Acme"}
+ ),
)
- with pytest.raises(BridgeError, match="taught writes are refused"):
+
+
+def _start(bridge: BrowserTeachBridge, app: _App) -> tuple[str, list[str]]:
+ seen: list[str] = []
+ started = bridge.dispatch(
+ "teach_start",
+ {
+ "name": "add_order",
+ "url": f"{app.base}/login",
+ "task": "Add an order",
+ "contract": "order",
+ "inputs": {"customer": "Acme"},
+ },
+ )
+ seen.append(json.dumps(started))
+ page = started["page"]
+ for args in (
+ {"action": "type", "ref": _ref(page, "Username"), "input": "username"},
+ {"action": "type", "ref": _ref(page, "Password"), "input": "password"},
+ ):
+ result = bridge.dispatch("teach_act", args)
+ seen.append(json.dumps(result))
+ page = result["page"]
+ result = bridge.dispatch(
+ "teach_act", {"action": "click", "ref": _ref(page, "Sign in")}
+ )
+ seen.append(json.dumps(result))
+ return result["page"], seen
+
+
+def _assert_no_password(texts: list[str]) -> None:
+ for text in texts:
+ for form in secret_forms(PASSWORD):
+ assert form not in text, form
+
+
+def test_an_undeclared_write_is_never_sent(
+ bridge: BrowserTeachBridge, app: _App
+) -> None:
+ page, seen = _start(bridge, app)
+ page = bridge.dispatch(
+ "teach_act",
+ {"action": "type", "ref": _ref(page, "Customer"), "input": "customer"},
+ )["page"]
+ page = bridge.dispatch(
+ "teach_act", {"action": "click", "ref": _ref(page, "Save order")}
+ )["page"]
+ with pytest.raises(
+ BridgeError, match="POST /account/close, which the write contract"
+ ):
bridge.dispatch(
- "teach_start",
- {
- "name": "add_order",
- "url": f"{app}/login",
- "task": "Add an order",
- "contract": "order",
- "inputs": {"customer": "Acme Ltd"},
- },
+ "teach_act", {"action": "click", "ref": _ref(page, "Close account")}
+ )
+ assert app.closed == 0
+ _assert_no_password(seen)
+
+
+def test_a_sign_in_value_stays_out_of_addresses_and_records(
+ bridge: BrowserTeachBridge, app: _App
+) -> None:
+ page, seen = _start(bridge, app)
+ for label in ("Search", "Customer"):
+ with pytest.raises(
+ BridgeError, match="sign-in value goes only into"
+ ) as refused:
+ bridge.dispatch(
+ "teach_act",
+ {"action": "type", "ref": _ref(page, label), "input": "password"},
+ )
+ seen.append(str(refused.value))
+ # Typed as plain text, it is not a value the signed teach authorization
+ # allows, so it isn't typed at all.
+ with pytest.raises(BridgeError, match="doesn't allow typing") as refused:
+ bridge.dispatch(
+ "teach_act",
+ {"action": "type", "ref": _ref(page, "Search"), "text": PASSWORD},
+ )
+ seen.append(str(refused.value))
+ _assert_no_password(seen)
+
+
+@pytest.mark.parametrize("mode", ["extra_field", "second_write"])
+def test_a_taught_write_sends_only_what_it_was_taught(
+ bridge: BrowserTeachBridge, app: _App, mode: str
+) -> None:
+ page, seen = _start(bridge, app)
+ page = bridge.dispatch(
+ "teach_act",
+ {"action": "type", "ref": _ref(page, "Customer"), "input": "customer"},
+ )["page"]
+ bridge.dispatch("teach_act", {"action": "click", "ref": _ref(page, "Save order")})
+ finished = bridge.dispatch("teach_finish", {})
+ seen.append(json.dumps(finished))
+ assert finished["status"] == "ready", finished
+ program = json.loads(Path(finished["program"]).read_text())
+ assert [w["write"] for w in program["writes"]] == ["POST /login", "POST /orders"]
+
+ good = bridge.dispatch("taught_add_order", {"inputs": {"customer": "Birch"}})
+ seen.append(json.dumps(good))
+ assert good["outcome"] == "done", good
+ assert good["values_declared_by"].startswith("agent")
+ assert good["authorization"] == "unbound"
+ assert good["seal_path"] and Path(good["seal_path"]).exists()
+ assert good["seal"] == "sealed"
+
+ app.mode = mode
+ bad = bridge.dispatch("taught_add_order", {"inputs": {"customer": "Cedar"}})
+ seen.append(json.dumps(bad))
+ assert bad["outcome"] == "needs_review", bad
+ assert bad["verified"] is False
+ assert "it was not sent" in bad["what_happened"]
+ customers = [o["customer"] for o in app.orders]
+ assert "ghost" not in customers # the extra write never left the browser
+ if mode == "extra_field":
+ assert "Cedar" not in customers # the changed save was stopped
+ _assert_no_password(seen)
+
+
+def test_run_1_sends_no_write_the_authorization_does_not_cover(
+ bridge: BrowserTeachBridge, app: _App
+) -> None:
+ """The authorization's write adapter reads run 1's save in the browser."""
+
+ app.mode = "rewrite"
+ page, seen = _start(bridge, app)
+ page = bridge.dispatch(
+ "teach_act",
+ {"action": "type", "ref": _ref(page, "Customer"), "input": "customer"},
+ )["page"]
+ with pytest.raises(BridgeError, match="outside the signed teach authorization"):
+ bridge.dispatch(
+ "teach_act", {"action": "click", "ref": _ref(page, "Save order")}
)
- folder = tmp_path / "tools" / "add_order"
- folder.mkdir(parents=True)
- (folder / bt.PROGRAM_FILE).write_text(
- json.dumps(
- {
- "schema": bt.PROGRAM_SCHEMA,
- "name": "add_order",
- "task": "Add an order",
- "url": f"{app}/login",
- "inputs": ["customer"],
- "steps": [],
- "writes": [],
- "result": {"kind": "write", "contract": "order"},
- }
+ assert app.orders == [] # Mallory's order never left the browser
+ _assert_no_password(seen)
+
+
+def test_run_1_presses_no_character_keys(bridge: BrowserTeachBridge, app: _App) -> None:
+ page, _seen = _start(bridge, app)
+ with pytest.raises(BridgeError, match="press takes only"):
+ bridge.dispatch(
+ "teach_act", {"action": "press", "ref": _ref(page, "Customer"), "key": "M"}
)
+
+
+def test_a_forbidden_control_is_refused_by_its_accessible_name(
+ app: _App, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Read from the browser's accessibility tree, by click or by keyboard."""
+
+ monkeypatch.setenv("P_USER", "clerk")
+ monkeypatch.setenv("P_PW", PASSWORD)
+ monkeypatch.setenv("OPENADAPT_SEAL_HOME", str(tmp_path / "seal"))
+ bridge = BrowserTeachBridge(
+ tmp_path / "tools",
+ tmp_path / "files",
+ contracts={"order": _contract(app.base)},
+ secrets={"username": "P_USER", "password": "P_PW"},
+ verify_dir=tmp_path / "verify",
+ teach_authorization=order_authority(
+ tmp_path / "authorization",
+ app.base,
+ {"customer": "Acme"},
+ forbidden_controls=["Close account"],
+ ),
)
- bridge = bt.BrowserTeachBridge(
- tmp_path / "tools", tmp_path / "files", contracts={"order": _contract(app)}
+ page, _seen = _start(bridge, app)
+ with pytest.raises(BridgeError, match="forbids this control"):
+ bridge.dispatch(
+ "teach_act", {"action": "click", "ref": _ref(page, "Close account")}
+ )
+ assert app.closed == 0
+
+
+def test_run_1_opens_no_websocket(bridge: BrowserTeachBridge, app: _App) -> None:
+ """Request routing doesn't see WebSockets; run 1 of a taught write opens none."""
+
+ page, _seen = _start(bridge, app)
+ app.mode = "socket"
+ with pytest.raises(BridgeError, match="WebSocket"):
+ bridge.dispatch("teach_act", {"action": "goto", "url": "/app"})
+ bridge.dispatch(
+ "teach_act",
+ {"action": "type", "ref": _ref(page, "Customer"), "input": "customer"},
+ )
+
+
+def test_a_control_below_the_fold_is_checked_where_it_is(
+ app: _App, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """With forbidden controls, an allowed save below the fold still goes."""
+
+ monkeypatch.setenv("P_USER", "clerk")
+ monkeypatch.setenv("P_PW", PASSWORD)
+ monkeypatch.setenv("OPENADAPT_SEAL_HOME", str(tmp_path / "seal"))
+ app.mode = "tall"
+ bridge = BrowserTeachBridge(
+ tmp_path / "tools",
+ tmp_path / "files",
+ contracts={"order": _contract(app.base)},
+ secrets={"username": "P_USER", "password": "P_PW"},
+ verify_dir=tmp_path / "verify",
+ teach_authorization=order_authority(
+ tmp_path / "authorization",
+ app.base,
+ {"customer": "Acme"},
+ forbidden_controls=["Close account"],
+ ),
)
- result = bridge.dispatch("taught_add_order", {"inputs": {"customer": "Birch"}})
- assert result["outcome"] == "did_not_run", result
- assert _orders(app) == before
+ page, _seen = _start(bridge, app)
+ page = bridge.dispatch(
+ "teach_act",
+ {"action": "type", "ref": _ref(page, "Customer"), "input": "customer"},
+ )["page"]
+ bridge.dispatch("teach_act", {"action": "click", "ref": _ref(page, "Save order")})
+ finished = bridge.dispatch("teach_finish", {})
+ assert finished["status"] == "ready", finished
diff --git a/packages/flow/tests/e2e/test_code_agent_default_e2e.py b/packages/flow/tests/e2e/test_code_agent_default_e2e.py
index 605ed04547..2688380893 100644
--- a/packages/flow/tests/e2e/test_code_agent_default_e2e.py
+++ b/packages/flow/tests/e2e/test_code_agent_default_e2e.py
@@ -130,7 +130,12 @@ def test_a_dropped_save_is_reported_as_not_saved(mockmed: str, tmp_path: Path) -
def _teach(
- tmp_path: Path, base: str, *, hold: bool, teach_authorization: object = None
+ tmp_path: Path,
+ base: str,
+ *,
+ hold: bool,
+ teach_authorization: object = None,
+ fault: str = "ok",
) -> object:
from openadapt_flow.agent.teach import TeachBridge, TeachConfig
@@ -138,7 +143,7 @@ def _teach(
TeachConfig(
verify=_verify_yaml(tmp_path, base),
cache_dir=tmp_path / "teach",
- url=f"{base}/?fault=ok&idempotency=demo#tasks",
+ url=f"{base}/?fault={fault}&idempotency=demo#tasks",
key_dir=tmp_path / "keys",
hold=hold,
reset=_reset(base),
@@ -267,7 +272,7 @@ def test_a_teach_run_draft_without_a_statement_is_refused_at_admission(
teach.close() # type: ignore[attr-defined]
-def test_a_teach_run_draft_under_a_statement_is_bound_and_admissible(
+def test_a_teach_run_draft_under_a_statement_passes_the_authorization_gate(
mockmed: str, tmp_path: Path
) -> None:
authority = _authority(tmp_path, mockmed)
@@ -301,7 +306,63 @@ def test_teach_run_cannot_type_what_the_statement_does_not_allow(
assert out["status"] == "ok", out
injected = SCRIPTS[1].replace("{note}", "Cleared for discharge")
out = teach.dispatch("teach_run", {"script": injected}) # type: ignore[attr-defined]
- assert out["status"] in ("refused", "error"), out
+ assert out["status"] == "refused", out
+ assert "typed_value_not_declared" in str(out["detail"]), out
assert _records(mockmed) == [] # nothing outside the statement was saved
+ report = teach.dispatch("teach_finish", {}) # type: ignore[attr-defined]
+ assert report["status"] != "draft", report
+ finally:
+ teach.close() # type: ignore[attr-defined]
+
+
+def test_a_teach_run_save_on_another_record_leaves_no_draft(
+ mockmed: str, tmp_path: Path
+) -> None:
+ """The save reaches the app under the statement, and the read-back judges it."""
+
+ teach = _teach(
+ tmp_path,
+ mockmed,
+ hold=False,
+ teach_authorization=_authority(tmp_path, mockmed),
+ fault="wrong_record",
+ )
+ try:
+ _start(teach)
+ out = teach.dispatch("teach_run", {"script": SCRIPTS[0]}) # type: ignore[attr-defined]
+ assert out["status"] == "ok", out
+ out = teach.dispatch( # type: ignore[attr-defined]
+ "teach_run", {"script": SCRIPTS[1].replace("{note}", NOTE)}
+ )
+ report = teach.dispatch("teach_finish", {}) # type: ignore[attr-defined]
+ assert report["status"] != "draft", (out, report)
+ jit = teach._compiler._jit() # type: ignore[attr-defined]
+ assert jit.candidate() is None # nothing an admission could take
+ finally:
+ teach.close() # type: ignore[attr-defined]
+
+
+def test_a_person_approved_teach_run_save_under_a_statement_is_bound(
+ mockmed: str, tmp_path: Path
+) -> None:
+ authority = _authority(tmp_path, mockmed)
+ teach = _teach(tmp_path, mockmed, hold=True, teach_authorization=authority)
+ try:
+ _start(teach)
+ assert teach.dispatch("teach_run", {"script": SCRIPTS[0]})["status"] == "ok" # type: ignore[attr-defined]
+ out = teach.dispatch( # type: ignore[attr-defined]
+ "teach_run", {"script": SCRIPTS[1].replace("{note}", NOTE)}
+ )
+ assert out["status"] == "needs_person", out
+ hold = teach.hold_needed({"script": out["script"]}, tool="teach_run") # type: ignore[attr-defined]
+ done = teach.dispatch( # type: ignore[attr-defined]
+ "teach_run", {"script": out["script"]}, held_by_person=hold["binding"]
+ )
+ assert done["status"] == "ok", done
+ assert [(r["patient_id"], r["note"]) for r in _records(mockmed)] == [
+ ("p1", NOTE)
+ ]
+ assert teach.dispatch("teach_finish", {})["status"] == "draft" # type: ignore[attr-defined]
+ assert _gate(teach, trust=authority)["task_id"] == "taught.add_note"
finally:
teach.close() # type: ignore[attr-defined]
diff --git a/packages/flow/tests/test_browser_teach.py b/packages/flow/tests/test_browser_teach.py
index 3850dcd6e0..ea811ab089 100644
--- a/packages/flow/tests/test_browser_teach.py
+++ b/packages/flow/tests/test_browser_teach.py
@@ -13,7 +13,7 @@
import re
import urllib.request
from pathlib import Path
-from typing import Iterator
+from typing import Any, Iterator
import pytest
@@ -311,35 +311,395 @@ def check():
_on(browser, check)
-def test_taught_writes_are_refused(tmp_path: Path) -> None:
- """No person's teach authorization binds a write taught in this lane."""
+# -- taught writes need a person's signed teach authorization ---------------------
+# Real approver keys on a real roster, the real verifier, real write contracts.
+# Every refusal here comes before a browser opens. tests/e2e/
+# test_browser_teach_write_e2e.py teaches and runs one end to end.
- bridge = bt.BrowserTeachBridge(tmp_path, tmp_path / "files")
- with pytest.raises(BridgeError, match="taught writes are refused"):
- bridge.dispatch(
- "teach_start",
- {
- "name": "file_order",
- "task": "File the order",
- "url": "http://127.0.0.1:1/",
- "inputs": {"item": "Widget"},
- "contract": "order",
+APP = "http://127.0.0.1:1"
+ORDER = {"customer": "Acme Ltd", "product": "Desk lamp", "units": "3"}
+
+
+def _order_contract() -> Any:
+ from openadapt_flow.verify import WriteContract
+
+ return WriteContract.from_dict(
+ {
+ "schema_version": "openadapt.verify/v1",
+ "writes": ["POST /login", "POST /orders"],
+ "verifier": {
+ "kind": "rest",
+ "base_url": APP,
+ "records_path": "/api/orders",
+ "records_key": "records",
},
+ "effects": [
+ {
+ "kind": "record_written",
+ "match": {k: {"param": k} for k in ORDER},
+ "expected_count": 1,
+ "count_new_only": True,
+ "timeout_s": 1.0,
+ }
+ ],
+ },
+ name="order",
+ )
+
+
+def _bridge(tmp_path: Path, authority: Any = None) -> bt.BrowserTeachBridge:
+ return bt.BrowserTeachBridge(
+ tmp_path / "tools",
+ tmp_path / "files",
+ contracts={"order": _order_contract()},
+ teach_authorization=authority,
+ )
+
+
+def _teach(bridge: bt.BrowserTeachBridge, name: str = "add_order", **inputs: str):
+ return bridge.dispatch(
+ "teach_start",
+ {
+ "name": name,
+ "url": f"{APP}/login",
+ "task": "Add an order",
+ "contract": "order",
+ "inputs": {**ORDER, **inputs},
+ },
+ )
+
+
+def _refused(bridge: bt.BrowserTeachBridge, code: str, **kw: Any) -> None:
+ with pytest.raises(BridgeError, match=re.escape(code)):
+ _teach(bridge, **kw)
+
+
+def test_a_covering_authorization_binds_run_1(tmp_path: Path) -> None:
+ from tests._teach_authorization_support import order_authority
+
+ bridge = _bridge(tmp_path, order_authority(tmp_path / "auth", APP, ORDER))
+ verification, statement, scope = bridge._authorize_write(
+ "add_order", f"{APP}/login", _order_contract(), ORDER, {}
+ )
+ assert verification.ok and statement.is_file()
+ assert dict(scope.record) == {"customer": "Acme Ltd"}
+ assert dict(scope.fields) == {"product": "Desk lamp", "units": "3"}
+ # The adapter reads run 1's save before it is sent.
+ body = b"customer=Acme+Ltd&product=Desk+lamp&units=3"
+ assert scope.request_refusal("POST", f"{APP}/orders", body) is None
+ other = b"customer=Birch+Co&product=Desk+lamp&units=3"
+ assert scope.request_refusal("POST", f"{APP}/orders", other) == (
+ "write_wrong_record"
+ )
+ assert scope.request_refusal("POST", f"{APP}/login", b"u=x") is None
+ assert scope.request_refusal("POST", f"{APP}/account/close", b"") == (
+ "write_unattributed"
+ )
+
+
+def test_no_statement_refuses_a_taught_write(tmp_path: Path) -> None:
+ _refused(_bridge(tmp_path), "teach_auth_missing")
+
+
+def test_a_statement_that_does_not_cover_the_write_refuses(tmp_path: Path) -> None:
+ from tests._teach_authorization_support import order_authority
+
+ cases = {
+ "another record kind": dict(record_kind="invoice"),
+ "no write adapter": dict(write_adapter=None),
+ "another tool": dict(name="close_account"),
+ }
+ for i, (_why, kw) in enumerate(cases.items()):
+ authority = order_authority(tmp_path / f"auth{i}", APP, ORDER, **kw)
+ code = (
+ "teach_auth_task_mismatch"
+ if "name" in kw
+ else "teach_auth_write_not_covered"
)
- folder = tmp_path / "file_order"
- folder.mkdir()
- (folder / bt.PROGRAM_FILE).write_text(
- json.dumps(
- {
- "schema": bt.PROGRAM_SCHEMA,
- "name": "file_order",
- "task": "File the order",
- "inputs": ["item"],
- "result": {"kind": "write", "contract": "order"},
- }
- )
+ _refused(_bridge(tmp_path / f"b{i}", authority), code)
+ # Fewer fields than the contract checks.
+ narrow = order_authority(
+ tmp_path / "narrow", APP, {"customer": "Acme Ltd"}, identity=("customer",)
)
- bridge = bt.BrowserTeachBridge(tmp_path, tmp_path / "files")
- result = bridge.dispatch("taught_file_order", {"inputs": {"item": "Widget"}})
+ _refused(_bridge(tmp_path / "bn", narrow), "teach_auth_input_not_declared")
+ # Run 1's values aren't the signed ones.
+ authority = order_authority(tmp_path / "values", APP, ORDER)
+ _refused(
+ _bridge(tmp_path / "bv", authority),
+ "teach_auth_input_mismatch",
+ customer="Birch Co",
+ )
+ # Another app.
+ elsewhere = order_authority(tmp_path / "else", "http://127.0.0.1:2", ORDER)
+ _refused(_bridge(tmp_path / "be", elsewhere), "teach_auth_write_not_covered")
+
+
+def test_an_expired_statement_refuses(tmp_path: Path) -> None:
+ from datetime import datetime, timedelta, timezone
+
+ from openadapt_flow.teach_authorization_fixture import LocalAuthorizer
+ from tests._teach_authorization_support import order_spec
+
+ person = LocalAuthorizer(tmp_path / "approvers", "clinic-lead")
+ statement = person.issue(
+ tmp_path / "statement",
+ order_spec(APP, ORDER),
+ now=datetime.now(timezone.utc) - timedelta(days=3),
+ valid_for=timedelta(days=1),
+ )
+ _refused(_bridge(tmp_path, person.authority(statement)), "teach_auth_expired")
+
+
+def test_an_edited_statement_refuses(tmp_path: Path) -> None:
+ from tests._teach_authorization_support import order_authority
+
+ authority = order_authority(tmp_path / "auth", APP, ORDER)
+ data = json.loads(authority.statement.read_text())
+ data["writes"]["fields"]["units"]["value"] = "300"
+ data["inputs"]["units"]["value"] = "300"
+ authority.statement.write_text(json.dumps(data))
+ _refused(_bridge(tmp_path, authority), "teach_auth_bad_signature", units="300")
+
+
+def test_the_wrong_signer_refuses(tmp_path: Path) -> None:
+ from openadapt_flow.teach_authorization_fixture import LocalAuthorizer
+ from tests._teach_authorization_support import order_spec
+
+ person = LocalAuthorizer(tmp_path / "approvers", "clinic-lead")
+ # A key on no roster.
+ stranger = LocalAuthorizer(person.key_dir, "night-desk", register=False)
+ statement = stranger.issue(tmp_path / "s1", order_spec(APP, ORDER))
+ _refused(
+ _bridge(tmp_path / "b1", person.authority(statement)),
+ "teach_auth_unknown_signer",
+ )
+ # A key that signs in the approver's name.
+ forged = stranger.issue(
+ tmp_path / "s2", order_spec(APP, ORDER), authorizer="clinic-lead"
+ )
+ _refused(
+ _bridge(tmp_path / "b2", person.authority(forged)),
+ "teach_auth_bad_signature",
+ )
+
+
+# -- running a taught write tool checks the authorization again --------------------
+
+
+def _taught_tool(tmp_path: Path, person: Any = None, **issue: Any) -> tuple:
+ """A sealed write tool on disk, as teach_finish leaves it."""
+
+ from openadapt_flow.teach_authorization_fixture import LocalAuthorizer
+ from tests._teach_authorization_support import admission_key, order_spec
+
+ person = person or LocalAuthorizer(tmp_path / "approvers", "clinic-lead")
+ admission_key(person.key_dir)
+ statement = person.issue(tmp_path / "statement", order_spec(APP, ORDER), **issue)
+ authority = person.authority(statement)
+ bridge = _bridge(tmp_path, authority)
+ from openadapt_flow import teach_authorization as ta
+
+ verification = ta.verify(statement, key_dir=person.key_dir, root=person.root)
+ folder = bridge.tools_dir / "add_order"
+ folder.mkdir(parents=True)
+ program = bridge._seal_program(
+ {
+ "schema": bt.PROGRAM_SCHEMA,
+ "name": "add_order",
+ "task": "Add an order",
+ "url": f"{APP}/login",
+ "origin": APP,
+ "inputs": sorted(ORDER),
+ "steps": [],
+ "writes": [],
+ "result": {"kind": "write", "contract": "order"},
+ },
+ (verification, statement),
+ folder,
+ )
+ (folder / bt.PROGRAM_FILE).write_text(json.dumps(program))
+ # A server started now lists the tool.
+ return _bridge(tmp_path, authority), program, person
+
+
+def test_a_taught_tool_runs_only_while_its_authorization_holds(tmp_path: Path) -> None:
+ bridge, program, person = _taught_tool(tmp_path)
+ assert bridge._write_refusal(program) is None
+ # The server restarted without the authorization's trust.
+ assert "needs a teach authorization" in _bridge(tmp_path)._write_refusal(program)
+ # An edited program.
+ assert "changed after it was taught" in bridge._write_refusal(
+ {**program, "origin": "http://127.0.0.1:2"}
+ )
+ # The signer was removed from the roster.
+ from openadapt_flow.teach_authorization_fixture import LocalAuthorizer
+
+ root = LocalAuthorizer(tmp_path / "r2" / "approvers", "admin")
+ lead = LocalAuthorizer(root.key_dir, "clinic-lead", added_by=root)
+ bridge2, program2, _ = _taught_tool(tmp_path / "r2", person=lead)
+ assert bridge2._write_refusal(program2) is None
+ root.remove(lead)
+ assert "teach_auth_unknown_signer" in bridge2._write_refusal(program2)
+
+
+def test_a_taught_tool_stops_when_its_authorization_expires(tmp_path: Path) -> None:
+ from datetime import datetime, timedelta, timezone
+
+ bridge, program, _ = _taught_tool(
+ tmp_path,
+ now=datetime.now(timezone.utc) - timedelta(days=3),
+ valid_for=timedelta(days=1),
+ )
+ assert "teach_auth_expired" in bridge._write_refusal(program)
+ result = bridge.dispatch("taught_add_order", {"inputs": ORDER})
assert result["outcome"] == "did_not_run"
assert "nothing was written" in result["what_happened"]
+
+
+def test_a_taught_tool_with_a_swapped_statement_does_not_run(tmp_path: Path) -> None:
+ from openadapt_flow import teach_authorization as ta
+ from tests._teach_authorization_support import order_spec
+
+ bridge, program, person = _taught_tool(tmp_path)
+ other = person.issue(tmp_path / "other", order_spec(APP, ORDER))
+ kept = bridge.tools_dir / "add_order" / ta.KEPT_DIR
+ for name in (ta.STATEMENT_FILE, ta.SIGNATURE_FILE):
+ (kept / name).write_bytes(other.with_name(name).read_bytes())
+ assert "teach_auth_digest_mismatch" in bridge._write_refusal(program)
+
+
+def test_the_record_key_is_pinned_even_when_signed_as_a_field() -> None:
+ """A statement may list the record's key under fields; it still binds it."""
+
+ from openadapt_flow.agent.example_app import OrderWrites
+ from openadapt_flow.teach_scope import TeachScope
+
+ scope = TeachScope(
+ task_id="browser-tool.add_order",
+ origins=(APP,),
+ record={"product": "Desk lamp"},
+ fields={"customer": "Acme Ltd", "units": "3"},
+ write_adapter=OrderWrites(),
+ )
+ url = f"{APP}/orders"
+ good = b"customer=Acme+Ltd&product=Desk+lamp&units=3"
+ evil = b"customer=Evil+Corp&product=Desk+lamp&units=3"
+ assert scope.request_refusal("POST", url, good) is None
+ assert scope.request_refusal("POST", url, evil) == "write_wrong_record"
+
+
+def test_a_statement_the_lane_cannot_hold_run_1_to_refuses(tmp_path: Path) -> None:
+ from datetime import timedelta
+
+ from openadapt_flow.teach_authorization_fixture import LocalAuthorizer
+ from tests._teach_authorization_support import order_authority, order_spec
+
+ person = LocalAuthorizer(tmp_path / "approvers", "clinic-lead")
+ long = person.issue(
+ tmp_path / "long", order_spec(APP, ORDER), valid_for=timedelta(days=200)
+ )
+ with pytest.raises(BridgeError, match="more than 90 days"):
+ _teach(_bridge(tmp_path / "b1", person.authority(long)))
+ rows = order_authority(tmp_path / "rows", APP, ORDER, expected_rows=2)
+ with pytest.raises(BridgeError, match="allows 2 rows"):
+ _teach(_bridge(tmp_path / "b2", rows))
+ # A pin whose key directory would come from the environment.
+ from openadapt_flow import teach_authorization as ta
+
+ unpinned = ta.TeachAuthority(rows.statement, approver_root=person.root)
+ _refused(_bridge(tmp_path / "b3", unpinned), "teach_auth_identity_unverified")
+
+
+def test_coverage_maps_literal_matches_and_refuses_other_checks() -> None:
+ from openadapt_flow.agent.example_app import OrderWrites
+ from openadapt_flow.teach_scope import TeachScope
+ from openadapt_flow.verify import WriteContract
+
+ def contract(effect: dict) -> Any:
+ return WriteContract.from_dict(
+ {
+ "schema_version": "openadapt.verify/v1",
+ "writes": ["POST /orders"],
+ "verifier": {
+ "kind": "rest",
+ "base_url": APP,
+ "records_path": "/api/orders",
+ "records_key": "records",
+ },
+ "effects": [effect],
+ },
+ name="order",
+ )
+
+ scope = TeachScope(
+ task_id="browser-tool.add_order",
+ origins=(APP,),
+ record={"customer": "Acme Ltd"},
+ fields={"product": "Desk lamp"},
+ write_adapter=OrderWrites(),
+ )
+ literal = contract(
+ {
+ "kind": "record_written",
+ "match": {"customer": {"param": "customer"}, "product": "Desk lamp"},
+ "expected_count": 1,
+ }
+ )
+ assert bt._contract_coverage(literal, scope, {"customer": "Acme Ltd"}, {}) is None
+ other = contract(
+ {
+ "kind": "record_written",
+ "match": {"customer": {"param": "customer"}, "product": "Chair"},
+ "expected_count": 1,
+ }
+ )
+ assert "isn't the value" in bt._contract_coverage(
+ other, scope, {"customer": "Acme Ltd"}, {}
+ )
+ narrower = contract(
+ {
+ "kind": "record_written",
+ "match": {"customer": {"param": "customer"}},
+ "expected_count": 1,
+ }
+ )
+ assert "aren't the fields" in bt._contract_coverage(
+ narrower, scope, {"customer": "Acme Ltd"}, {}
+ )
+
+
+def test_teaching_a_tool_again_replaces_its_statement(tmp_path: Path) -> None:
+ from openadapt_flow import teach_authorization as ta
+ from tests._teach_authorization_support import order_spec
+
+ bridge, program, person = _taught_tool(tmp_path)
+ newer = person.issue(tmp_path / "newer", order_spec(APP, ORDER))
+ verification = ta.verify(newer, key_dir=person.key_dir, root=person.root)
+ folder = bridge.tools_dir / "add_order"
+ again = bridge._seal_program(
+ {k: v for k, v in program.items() if k not in ("signature",)},
+ (verification, newer),
+ folder,
+ )
+ kept = folder / ta.KEPT_DIR / ta.STATEMENT_FILE
+ assert kept.read_bytes() == newer.read_bytes()
+ assert bridge._write_refusal(again) is None
+ assert "teach_auth_digest_mismatch" in bridge._write_refusal(program)
+
+
+def test_running_tools_needs_only_the_config_pin(tmp_path: Path) -> None:
+ from openadapt_flow import teach_authorization as ta
+
+ _, program, person = _taught_tool(tmp_path)
+ pin = ta.TeachAuthority(
+ Path("."), key_dir=person.key_dir, approver_root=person.root
+ )
+ bridge = bt.BrowserTeachBridge(
+ tmp_path / "tools",
+ tmp_path / "files",
+ contracts={"order": _order_contract()},
+ approvers=pin,
+ )
+ assert bridge._write_refusal(program) is None
+ # It teaches nothing new without a statement.
+ _refused(bridge, "teach_auth_missing", name="add_invoice")
diff --git a/packages/flow/tests/test_browser_teach_containment.py b/packages/flow/tests/test_browser_teach_containment.py
new file mode 100644
index 0000000000..7cc4a14786
--- /dev/null
+++ b/packages/flow/tests/test_browser_teach_containment.py
@@ -0,0 +1,695 @@
+"""Real headless browser guards on fresh credential-free loopback servers."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from types import SimpleNamespace
+from typing import Any
+from urllib.parse import parse_qs, urlsplit
+
+import pytest
+from playwright.sync_api import sync_playwright
+
+from openadapt_flow.agent import browser_teach as bt
+from openadapt_flow.agent.bridge import BridgeError
+from openadapt_flow.backends import worker_containment
+from openadapt_flow.teach_scope import TeachScope, WriteClaim, control_labels_at
+from openadapt_flow.wire_check import body_shape
+
+BODY = b"record=expected&value=synthetic"
+
+
+class App:
+ def __init__(self) -> None:
+ self.received: list[tuple[str, str]] = []
+ self.ws_handshakes = 0
+ self.ws_frames: list[bytes] = []
+ self.frame_received = threading.Event()
+ self.html: str | None = None
+ self.frame_url: str | None = None
+ app = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None: # noqa: N802
+ self.respond("GET")
+
+ def do_POST(self) -> None: # noqa: N802
+ self.rfile.read(int(self.headers.get("Content-Length", "0")))
+ self.respond("POST")
+
+ def respond(self, method: str) -> None:
+ parts = urlsplit(self.path)
+ app.received.append((method, parts.path))
+ if parts.path == "/ws":
+ key = self.headers.get("Sec-WebSocket-Key")
+ if (
+ not key
+ or self.headers.get("Upgrade", "").lower() != "websocket"
+ ):
+ self.send_response(400)
+ self.end_headers()
+ return
+ accept = base64.b64encode(
+ hashlib.sha1(
+ (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()
+ ).digest()
+ ).decode()
+ self.send_response(101)
+ self.send_header("Upgrade", "websocket")
+ self.send_header("Connection", "Upgrade")
+ self.send_header("Sec-WebSocket-Accept", accept)
+ self.end_headers()
+ self.wfile.flush()
+ app.ws_handshakes += 1
+ self.close_connection = True
+ self.connection.settimeout(2)
+ try:
+ # This disposable server only accepts one small,
+ # masked text frame from our own capability control.
+ header = self.rfile.read(2)
+ if (
+ len(header) != 2
+ or header[0] != 0x81
+ or not header[1] & 0x80
+ ):
+ return
+ size = header[1] & 0x7F
+ if size >= 126:
+ return
+ mask = self.rfile.read(4)
+ payload = self.rfile.read(size)
+ if len(mask) != 4 or len(payload) != size:
+ return
+ app.ws_frames.append(
+ bytes(
+ value ^ mask[i % 4] for i, value in enumerate(payload)
+ )
+ )
+ app.frame_received.set()
+ except OSError:
+ return
+ return
+ if parts.path == "/redirect307":
+ self.send_response(307)
+ self.send_header("Location", "/write")
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+ return
+ if parts.path == "/write":
+ self.send_response(303)
+ self.send_header("Location", "/final")
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+ return
+ kind = parse_qs(parts.query).get("attempt", [""])[0]
+ content_type = "text/html"
+ if parts.path == "/worker.js":
+ kind = parse_qs(parts.query).get("kind", [""])[0]
+ text = (
+ "new WebSocket("
+ + json.dumps(app.base.replace("http:", "ws:") + "/ws")
+ + ");"
+ if kind == "socket"
+ else "fetch("
+ + json.dumps(app.base + "/write")
+ + ", {method:'POST',body:'record=expected&value=synthetic'});"
+ )
+ content_type = "application/javascript"
+ elif parts.path == "/frame":
+ text = ""
+ elif parts.path == "/frame-redirect":
+ text = ""
+ elif app.html is not None:
+ text = app.html
+ else:
+ script = ""
+ if kind == "page-socket":
+ script = (
+ "new WebSocket("
+ + json.dumps(app.base.replace("http:", "ws:") + "/ws")
+ + ");"
+ )
+ elif kind:
+ constructor = "SharedWorker" if kind == "shared" else "Worker"
+ script = f"new {constructor}('/worker.js?kind={kind}');"
+ text = (
+ "Synthetic form "
+ ""
+ ""
+ )
+ if app.frame_url:
+ text += (
+ ""
+ )
+ body = text.encode()
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *args: Any) -> None:
+ pass
+
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ self.base = f"http://127.0.0.1:{self.server.server_port}"
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+
+ @property
+ def writes(self) -> list[tuple[str, str]]:
+ return [r for r in self.received if r[1] in ("/write", "/ws")]
+
+ def close(self) -> None:
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join()
+
+
+@pytest.fixture
+def app():
+ found = App()
+ try:
+ yield found
+ finally:
+ found.close()
+
+
+@pytest.fixture
+def plain_browser():
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(headless=True)
+ try:
+ yield browser
+ finally:
+ browser.close()
+
+
+class Adapter:
+ def claim(self, method: str, url: str, body: bytes | None) -> WriteClaim | None:
+ if urlsplit(url).path != "/write":
+ return None
+ values = parse_qs(
+ urlsplit(url).query if method == "GET" else (body or b"").decode()
+ )
+ return WriteClaim(
+ record={"record": values.get("record", [""])[0]},
+ fields={"value": values.get("value", [""])[0]},
+ )
+
+
+def scope(app: App) -> TeachScope:
+ return TeachScope(
+ task_id="browser-tool.synthetic",
+ origins=(app.base,),
+ record={"record": "expected"},
+ fields={"value": "synthetic"},
+ write_adapter=Adapter(),
+ allowed_write_paths=("/redirect307",),
+ )
+
+
+def program(app: App, attempt: str = "") -> dict[str, Any]:
+ return {
+ "origin": app.base,
+ "url": app.base + "/?attempt=" + attempt,
+ "steps": [{"op": "click", "target": {"css": "#ready", "label": "Continue"}}],
+ }
+
+
+@pytest.mark.parametrize(
+ "attempt,method", [("shared", "POST"), ("dedicated", "POST"), ("socket", "GET")]
+)
+def test_worker_capability_control(plain_browser, app, attempt, method):
+ context = plain_browser.new_context(service_workers="block")
+ try:
+ page = context.new_page()
+ page.goto(app.base + "/?attempt=" + attempt)
+ page.locator("#ready").wait_for()
+ page.wait_for_timeout(300)
+ assert any(r[0] == method for r in app.writes), app.received
+ finally:
+ context.close()
+
+
+@pytest.mark.parametrize("attempt", ["shared", "dedicated", "socket"])
+def test_teaching_worker_attempt_is_refused(app, tmp_path, attempt):
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("POST /write",),
+ scope=scope(app),
+ )
+ try:
+ browser.page.goto(app.base + "/?attempt=" + attempt)
+ browser.page.locator("#ready").wait_for()
+ browser.page.wait_for_timeout(100)
+ browser.settle()
+ assert browser.blocked, "a blocked worker attempt must halt teaching"
+ assert not browser.containment.ok
+ assert worker_containment.WORKER_REFUSED in browser.containment.violations
+ assert worker_containment.WORKER_STARTED not in browser.containment.violations
+ assert app.writes == []
+ finally:
+ browser.close()
+
+
+@pytest.mark.timeout(20)
+@pytest.mark.parametrize("attempt", ["shared", "dedicated", "socket", "page-socket"])
+def test_every_compiled_call_refuses_the_attempt(app, tmp_path, attempt):
+ for _call in range(2):
+ result = bt._run_steps(
+ program(app, attempt),
+ {},
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("POST /write",),
+ pinned=[],
+ )
+ assert result.stopped is not None
+ assert result.blocked, "a later call must retain the browser refusal"
+ assert app.ws_handshakes == 0
+ assert app.ws_frames == []
+ assert app.writes == []
+
+
+@pytest.mark.timeout(15)
+def test_page_websocket_capability_control(plain_browser, app):
+ page = plain_browser.new_page()
+ try:
+ page.goto(app.base)
+ page.evaluate(
+ "url => {const ws=new WebSocket(url);ws.onopen=()=>ws.send('synthetic frame');}",
+ app.base.replace("http:", "ws:") + "/ws",
+ )
+ assert app.frame_received.wait(2)
+ assert app.ws_handshakes == 1
+ assert app.ws_frames == [b"synthetic frame"]
+ finally:
+ page.close()
+
+
+@pytest.mark.timeout(15)
+def test_teaching_page_websocket_is_refused(app, tmp_path):
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("POST /write",),
+ scope=scope(app),
+ )
+ try:
+ browser.page.goto(app.base + "/?attempt=page-socket")
+ browser.page.locator("#ready").wait_for()
+ browser.settle()
+ assert any("WebSocket" in reason for reason in browser.blocked)
+ assert browser._pending_sockets == []
+ assert app.ws_handshakes == 0
+ assert app.ws_frames == []
+ assert app.writes == []
+ finally:
+ browser.close()
+
+
+@pytest.mark.timeout(15)
+def test_websocket_close_error_keeps_the_refusal(app, tmp_path, monkeypatch):
+ browser = bt._Browser(app.base, tmp_path, True, allow_writes=True)
+ try:
+ browser.page.goto(app.base + "/?attempt=page-socket")
+ browser.page.locator("#ready").wait_for()
+ assert len(browser._pending_sockets) == 1
+
+ def unreadable_close(**kwargs):
+ raise RuntimeError("synthetic close failure")
+
+ monkeypatch.setattr(browser._pending_sockets[0], "close", unreadable_close)
+ browser.settle()
+ assert "a refused WebSocket could not be closed" in browser.blocked
+ assert browser._pending_sockets == []
+ assert app.ws_handshakes == 0
+ assert app.ws_frames == []
+ assert app.writes == []
+ finally:
+ browser.close()
+ assert browser._closed
+
+
+def test_containment_problem_refuses_before_navigation(app, tmp_path, monkeypatch):
+ monkeypatch.setattr(
+ worker_containment,
+ "contain_workers",
+ lambda *a: worker_containment.WorkerContainment(problem="coverage unavailable"),
+ )
+ with pytest.raises(BridgeError, match="coverage unavailable"):
+ bt._Browser(app.base, tmp_path, True, allow_writes=True)
+ assert app.received == []
+
+
+def test_containment_state_is_checked_without_callback(app, tmp_path):
+ browser = bt._Browser(app.base, tmp_path, True, allow_writes=True)
+ try:
+ browser.containment.violations.append("synthetic lost callback")
+ browser.settle()
+ assert "synthetic lost callback" in browser.blocked
+ finally:
+ browser.close()
+
+
+def test_redirected_post_is_checked_before_second_hop(app, tmp_path):
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("POST /redirect307",),
+ scope=scope(app),
+ )
+ try:
+ browser.page.goto(app.base)
+ with pytest.raises(Exception, match="Failed to fetch"):
+ browser.page.evaluate(
+ "fetch('/redirect307', {method:'POST',body:'synthetic'})"
+ )
+ browser.settle()
+ assert ("POST", "/redirect307") in app.received
+ assert browser.blocked
+ assert app.writes == []
+ finally:
+ browser.close()
+
+
+def test_allowed_save_and_303_preserve_one_write(app, tmp_path):
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("POST /write",),
+ scope=scope(app),
+ )
+ try:
+ browser.page.goto(app.base)
+ browser.page.locator("#save").click()
+ browser.settle()
+ assert browser.page.url == app.base + "/final"
+ assert browser.blocked == []
+ assert app.writes == [("POST", "/write")]
+ assert browser.writes_sent == ["POST /write"]
+ finally:
+ browser.close()
+
+
+def test_declared_get_write_is_attributed_and_pinned(app, tmp_path):
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("GET /write",),
+ scope=scope(app),
+ )
+ try:
+ browser.page.goto(app.base)
+ with pytest.raises(Exception, match="Failed to fetch"):
+ browser.page.evaluate("fetch('/write?record=other&value=synthetic')")
+ browser.settle()
+ assert browser.blocked
+ assert app.writes == []
+ assert browser.writes_sent == []
+ finally:
+ browser.close()
+ request = SimpleNamespace(
+ method="GET", url=app.base + "/write?record=expected&value=synthetic"
+ )
+ pinned = [{"write": "GET /write", "shape": body_shape(request)}]
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("GET /write",),
+ pinned=pinned,
+ )
+ try:
+ browser.page.goto(app.base)
+ with pytest.raises(Exception, match="Failed to fetch"):
+ browser.page.evaluate(
+ "fetch('/write?record=expected&value=synthetic&extra=value')"
+ )
+ browser.settle()
+ assert browser.blocked
+ assert app.writes == []
+ finally:
+ browser.close()
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("GET /write",),
+ pinned=pinned,
+ )
+ try:
+ browser.page.goto(app.base)
+ browser.page.evaluate("fetch('/write?record=expected&value=synthetic')")
+ browser.settle()
+ assert browser.blocked == []
+ assert browser.writes_sent == ["GET /write"]
+ assert app.writes == [("GET", "/write")]
+ finally:
+ browser.close()
+
+
+WRAPPED = """
+Delete record Continue """
+
+
+def test_wrapped_link_gap_is_not_the_click_point(plain_browser, app):
+ app.html = WRAPPED
+ page = plain_browser.new_page()
+ try:
+ page.goto(app.base)
+ el = page.locator("#wrapped")
+ box = el.bounding_box()
+ labels = control_labels_at(
+ page, int(box["x"] + box["width"] / 2), int(box["y"] + box["height"] / 2)
+ )
+ assert labels is not None and "Delete record" not in labels
+ el.click()
+ assert page.evaluate("window.clicked") is not None
+ with pytest.raises(bt.StepStopped, match="forbids this control"):
+ bt._check_forbidden(
+ page,
+ SimpleNamespace(forbidden_controls={"delete record"}),
+ "click",
+ el,
+ None,
+ )
+ assert app.writes == []
+ finally:
+ page.close()
+
+
+def test_guarded_fragment_click_uses_the_checked_point(plain_browser, app):
+ app.html = WRAPPED.replace("Delete record", "Open report")
+ page = plain_browser.new_page()
+ try:
+ page.goto(app.base)
+ point = bt._check_forbidden(
+ page,
+ SimpleNamespace(forbidden_controls={"delete record"}),
+ "click",
+ page.locator("#wrapped"),
+ None,
+ )
+ assert point is not None
+ page.mouse.click(*point)
+ assert page.evaluate("window.clicked") == list(point)
+ finally:
+ page.close()
+
+
+def test_tab_enter_checks_visible_name_as_well_as_accessible_name(plain_browser, app):
+ app.html = """Start Delete record
"""
+ page = plain_browser.new_page()
+ try:
+ page.goto(app.base)
+ page.locator("#start").focus()
+ page.keyboard.press("Tab")
+ assert page.locator("#danger").evaluate("e=>e===document.activeElement")
+ with pytest.raises(bt.StepStopped, match="forbids this control"):
+ bt._check_forbidden(
+ page,
+ SimpleNamespace(forbidden_controls={"delete record"}),
+ "press",
+ None,
+ "Enter",
+ )
+ assert app.writes == []
+ # Positive capability control: the key really would reach a write.
+ with page.expect_request("**/write"):
+ page.keyboard.press("Enter")
+ page.wait_for_timeout(100)
+ assert app.writes == [("POST", "/write")]
+ finally:
+ page.close()
+
+
+def test_same_process_frame_redirect_is_guarded(app, tmp_path):
+ app.frame_url = app.base + "/frame-redirect"
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("POST /redirect307",),
+ )
+ try:
+ browser.page.goto(app.base)
+ browser.page.wait_for_timeout(400)
+ browser.settle()
+ assert ("POST", "/redirect307") in app.received
+ assert browser.blocked
+ assert app.writes == []
+ finally:
+ browser.close()
+
+
+@pytest.fixture
+def separate_frame_channel(monkeypatch):
+ # Headless shell keeps these loopback frames in one process. The public
+ # Chromium channel's new headless mode creates actual child targets.
+ from playwright.sync_api import BrowserType
+
+ launch = BrowserType.launch
+
+ def new_headless(browser_type, *args, **kwargs):
+ assert kwargs.get("headless") is True
+ kwargs["channel"] = "chromium"
+ return launch(browser_type, *args, **kwargs)
+
+ monkeypatch.setattr(BrowserType, "launch", new_headless)
+
+
+def _separate_frame_document(app, child):
+ child.html = """Post Socket """
+ app.frame_url = child.base.replace("127.0.0.1", "localhost") + "/"
+
+
+def _assert_separate_frame(context, page, frame):
+ session = context.new_cdp_session(frame)
+ try:
+ child = session.send("Target.getTargetInfo", {})["targetInfo"]
+ main_session = context.new_cdp_session(page)
+ try:
+ main = main_session.send("Target.getTargetInfo", {})["targetInfo"]
+ finally:
+ main_session.detach()
+ assert child["type"] == "iframe"
+ assert child["targetId"] != main["targetId"]
+ finally:
+ session.detach() # requires an actual independent session, never skipped
+
+
+@pytest.mark.timeout(20)
+def test_separate_frame_capability_control(app, separate_frame_channel):
+ child = App()
+ _separate_frame_document(app, child)
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(headless=True)
+ context = browser.new_context()
+ try:
+ page = context.new_page()
+ page.goto(app.base)
+ frame = next(f for f in page.frames if f is not page.main_frame)
+ frame.locator("#post").wait_for()
+ _assert_separate_frame(context, page, frame)
+ frame.locator("#post").click()
+ page.wait_for_timeout(100)
+ assert child.writes == [("POST", "/write")]
+ frame.locator("#socket").click()
+ assert child.frame_received.wait(2)
+ assert child.ws_handshakes == 1
+ assert child.ws_frames == [b"synthetic frame"]
+ finally:
+ context.close()
+ browser.close()
+ child.close()
+
+
+@pytest.mark.timeout(20)
+def test_uncovered_separate_frame_refuses_before_request(
+ app, tmp_path, separate_frame_channel
+):
+ child = App()
+ _separate_frame_document(app, child)
+ browser = bt._Browser(
+ app.base, tmp_path, True, allow_writes=True, allowed_writes=("POST /write",)
+ )
+ try:
+ browser.page.goto(app.base)
+ frame = next(f for f in browser.page.frames if f is not browser.page.main_frame)
+ frame.locator("#post").wait_for()
+ _assert_separate_frame(browser.context, browser.page, frame)
+ frame.evaluate(
+ "() => {document.querySelector('#post').click();document.querySelector('#socket').click();}"
+ )
+ browser.page.wait_for_timeout(150)
+ browser.settle()
+ assert any("frame" in reason for reason in browser.blocked)
+ assert any("WebSocket" in reason for reason in browser.blocked)
+ assert child.writes == []
+ assert child.ws_handshakes == 0
+ assert child.ws_frames == []
+ finally:
+ browser.close()
+ child.close()
+
+
+@pytest.mark.timeout(15)
+def test_declared_get_write_rejects_a_blank_extra_field(app, tmp_path):
+ request = SimpleNamespace(
+ method="GET", url=app.base + "/write?record=expected&value=synthetic"
+ )
+ pinned = [{"write": "GET /write", "shape": body_shape(request)}]
+ browser = bt._Browser(
+ app.base,
+ tmp_path,
+ True,
+ allow_writes=True,
+ allowed_writes=("GET /write",),
+ pinned=pinned,
+ )
+ try:
+ browser.page.goto(app.base)
+ error = None
+ try:
+ browser.page.evaluate(
+ "fetch('/write?record=expected&value=synthetic&extra=')"
+ )
+ except Exception as exc:
+ error = str(exc)
+ browser.settle()
+ assert error is not None and "Failed to fetch" in error, {
+ "error": error,
+ "received": app.received,
+ "sent": browser.writes_sent,
+ }
+ assert browser.blocked
+ assert app.writes == []
+ assert browser.writes_sent == []
+ finally:
+ browser.close()
diff --git a/packages/flow/tests/test_teach_authorization.py b/packages/flow/tests/test_teach_authorization.py
index 112e0c597e..112df125ad 100644
--- a/packages/flow/tests/test_teach_authorization.py
+++ b/packages/flow/tests/test_teach_authorization.py
@@ -16,13 +16,13 @@
from openadapt_flow import teach_authorization as ta
from openadapt_flow.jit.adapters import Op
-from openadapt_flow.teach_authorization_fixture import LocalAuthorizer
-from openadapt_flow.teach_scope import ScopeViolation, TeachGuard, TeachScope
from tests._teach_scope import (
EncounterWrites,
encounter_authorization_spec,
encounter_scope,
)
+from openadapt_flow.teach_authorization_fixture import LocalAuthorizer
+from openadapt_flow.teach_scope import ScopeViolation, TeachGuard, TeachScope
BASE = "http://127.0.0.1:8765"
NOTE = "Synthetic follow-up in two weeks"
@@ -332,16 +332,35 @@ def test_statement_rules():
# -- the guard enforces the forbidden list -----------------------------------------
-class _Page:
- """Enough of a Playwright page for the guard's probes."""
-
- url = BASE + "/"
+PAGE = """Chart
+
+Save encounter
+ delete ENCOUNTER
+X
+just text
+"""
+
+#: A page that lies to any script asking what a control is called.
+LYING = PAGE.replace(
+ "",
+ "
synthetic caf\u00e9",
+)
- def __init__(self, label: str = "", link: Any = None) -> None:
- self.label, self.link = label, link
- def evaluate(self, script: str, args: Any) -> Any:
- return self.label if "aria-label" in script else self.link
+@pytest.fixture
+def chromium():
+ sync_api = pytest.importorskip("playwright.sync_api")
+ with sync_api.sync_playwright() as pw:
+ try:
+ browser = pw.chromium.launch()
+ except Exception as exc: # noqa: BLE001 - no browser installed
+ pytest.skip(f"no Chromium: {exc}")
+ page = browser.new_page()
+ yield page
+ browser.close()
def _guard(tmp_path: Path, person: LocalAuthorizer) -> TeachGuard:
@@ -349,34 +368,145 @@ def _guard(tmp_path: Path, person: LocalAuthorizer) -> TeachGuard:
return TeachGuard(ta.bind(_require(person, statement)))
-def test_forbidden_controls_and_actions(tmp_path, person):
- guard = _guard(tmp_path, person)
- with pytest.raises(ScopeViolation) as err:
- guard.check_ops([Op("click", x=1, y=1)], page=_Page(" delete ENCOUNTER "))
- assert err.value.code == "control_forbidden"
- guard.check_ops([Op("click", x=1, y=1)], page=_Page("Save encounter"))
+def _at(page, selector: str) -> Op:
+ box = page.locator(selector).bounding_box()
+ return Op("click", x=int(box["x"] + box["width"] / 2), y=int(box["y"] + 4))
+
+
+def _violation(fn, *args, **kw) -> str:
with pytest.raises(ScopeViolation) as err:
- guard.check_ops([Op("drag", x=1, y=1, end_x=2, end_y=2)])
- assert err.value.code == "forbidden_action"
+ fn(*args, **kw)
+ return err.value.code
+
+
+def test_forbidden_controls_are_read_from_the_browser(tmp_path, person, chromium):
+ guard = _guard(tmp_path, person)
+ for html in (PAGE, LYING):
+ chromium.set_content(html)
+ guard.check_ops([_at(chromium, "#save")], page=chromium)
+ guard.check_ops([_at(chromium, "#plain")], page=chromium)
+ # Its visible text, and an icon button's accessible name.
+ for forbidden in ("#del", "#icon"):
+ code = _violation(
+ guard.check_ops, [_at(chromium, forbidden)], page=chromium
+ )
+ assert code == "control_forbidden", (html is LYING, forbidden)
+ # A click with no position can't be checked.
assert (
- guard.scope.request_refusal("DELETE", BASE + "/api/encounter/1", None)
- == "forbidden_action"
+ _violation(guard.check_ops, [Op("click")], page=chromium) == "control_forbidden"
)
-def test_a_broken_label_probe_refuses_the_click(tmp_path, person):
- class _Broken(_Page):
- def evaluate(self, script: str, args: Any) -> Any:
- if "aria-label" in script:
- raise RuntimeError("the page overrode elementFromPoint")
- return None
+def test_a_key_that_reaches_a_forbidden_control_is_refused(tmp_path, person, chromium):
+ guard = _guard(tmp_path, person)
+ for html in (PAGE, LYING):
+ chromium.set_content(html)
+ chromium.focus("#note")
+ guard.check_key(Op("type", text=NOTE), chromium)
+ chromium.keyboard.press("Tab") # to Save encounter
+ guard.check_key(Op("press", key="Enter"), chromium)
+ chromium.keyboard.press("Tab") # to the forbidden one
+ for op in (
+ Op("press", key="Enter"),
+ Op("press", key="Space"),
+ Op("press", key="a"),
+ Op("hotkey", key="Enter", modifiers=("Shift",)),
+ Op("type", text=NOTE),
+ ):
+ assert _violation(guard.check_key, op, chromium) == "control_forbidden"
+ # Nothing to read the focus from.
+ assert _violation(guard.check_key, Op("press", key="Enter"), None) == (
+ "control_forbidden"
+ )
+
+def test_a_key_in_a_frame_or_a_form_is_checked_where_it_lands(
+ tmp_path, person, chromium
+):
guard = _guard(tmp_path, person)
- with pytest.raises(ScopeViolation) as err:
- guard.check_ops([Op("click", x=1, y=1)], page=_Broken())
- assert err.value.code == "control_forbidden"
- # A control with no label is not a forbidden one.
- guard.check_ops([Op("click", x=1, y=1)], page=_Page(""))
+ # Focus inside a frame.
+ chromium.set_content(
+ '"
+ )
+ frame = chromium.frame_locator("iframe")
+ frame.locator("button", has_text="Delete encounter").focus()
+ assert _violation(guard.check_key, Op("press", key="Enter"), chromium) == (
+ "control_forbidden"
+ )
+ frame.locator("button", has_text="Save").focus()
+ guard.check_key(Op("press", key="Enter"), chromium)
+ # Enter in a field sends the form with its default button.
+ chromium.set_content(
+ "
"
+ )
+ chromium.focus("input")
+ guard.check_key(Op("type", text=NOTE), chromium)
+ for op in (Op("press", key="Enter"), Op("type", text=NOTE + "\n")):
+ assert _violation(guard.check_key, op, chromium) == "control_forbidden"
+ chromium.set_content(
+ "
"
+ )
+ chromium.focus("input")
+ guard.check_key(Op("press", key="Enter"), chromium) # Save is the default
+ # The browser's own default button, not a re-implementation of it.
+ for html in (
+ # A button in a shadow root isn't the form's.
+ ""
+ "
",
+ ):
+ chromium.set_content(html)
+ chromium.focus("input")
+ assert _violation(guard.check_key, Op("press", key="Enter"), chromium) == (
+ "control_forbidden"
+ ), html
+ # A key chord can activate a control by its access key.
+ assert _violation(
+ guard.check_key, Op("hotkey", key="d", modifiers=("Alt",)), chromium
+ ) == ("control_forbidden")
+ # A control with no role, and a label for a forbidden control.
+ chromium.set_content(
+ '