diff --git a/.gitleaksignore b/.gitleaksignore index b61b5d9e4..6c10f7ece 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -60,3 +60,6 @@ e8ec96725c4451aa64cb97230c18d04b55d98503:packages/flow/tests/e2e/test_mcp_teach_ # test_network_capture_redact_json.py; the next commit builds them at run time. 52502adf407793c211fb3c0ac5aba37556206d8c:packages/flow/tests/test_network_capture_redact_json.py:generic-api-key:17 52502adf407793c211fb3c0ac5aba37556206d8c:packages/flow/tests/test_network_capture_redact_json.py:generic-api-key:146 +# PR #1543: `arg=self._fill_value_key` names a per-backend page key (a uuid made at +# runtime), not a credential. The line no longer exists after 81d00f5a4. +37605dd5fd04a1ef8517d3ec078ed88348c0a8f4:packages/flow/openadapt_flow/backends/playwright_backend.py:generic-api-key:1807 diff --git a/packages/flow/docs/LIMITS.md b/packages/flow/docs/LIMITS.md index 6baf20a49..625555937 100644 --- a/packages/flow/docs/LIMITS.md +++ b/packages/flow/docs/LIMITS.md @@ -375,6 +375,48 @@ The binding ties the click to the entry the run made. It doesn't check that the entry is right: the values typed come from the run's parameters, and only a record check can confirm what the application saved. +A labelled save on a create-new form (Trac's **Create ticket**, NetBox's +**Create**) has the same problem: no existing record is on screen next to it, +so it compiled with no identity check, and a policy that refuses unarmed saves +refused it. Its record is the one the run is typing into the form. When the +recording shows the save is the submit button of the one form that owns every +field typed since the last save, the compiler gives it a **form identity** +(`Anchor.form_identity`). Before the click, a run requires (identity mode +`form`): + +- the button is still that form's submit button, and the form owns exactly the + fields this run typed into, each holding exactly what the run typed; +- the form submits to the same origin and path with the same method, and would + send the same set of field names and types, hidden ones included, so an edit + form that looks like the create form, or an address, method or record id a + script changed or added, halts; +- every field the run did not type into (a hidden parent or record id, a + select left on its default, a checked box) holds its demonstrated value, + stored as a salted digest. Hidden tokens are not compared: ones named like a + token (`csrf`, `token`, `nonce` and the like), whose value changes with every + page load, and ones that changed while the demonstration was typed. A + program that should create under a different parent on each run therefore + halts until that parent is checked another way. + +During the click, a submit from any other form, or from the right form without +its save button (`requestSubmit()` from a click handler), is cancelled and +halts the run. A script that submits with `form.submit()` or `fetch` sends no +submit event, so the guard doesn't see it. + +A backend that can't read the page leaves the identity unreadable, which halts +too. Each field must hold exactly what the run typed, so a controlled input +that reformats the value halts: one that reformats on every keystroke stops at +the typed-value read-back, and one that reformats when it loses focus (a +phone-number mask, for example) stops at the form check. A field that a React +keyed remount replaces with a new element, even with the same value, also +halts: the new element isn't one the run typed into. A submit button outside +the `
+ + + +{_TAMPER if variant == "tamper" else ""} +{_REFORMAT if variant == "reformat" else ""} +{_BLUR_FORMAT if variant == "blurformat" else ""} +{f"" if script else ""} +""" + + +def _serve() -> tuple[str, Callable[[], None]]: + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 (http.server API) + query = parse_qs(urlsplit(self.path).query) + body = _page(query.get("v", ["plain"])[0]).encode() + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args: object) -> None: + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return f"http://127.0.0.1:{server.server_address[1]}/newticket", server.shutdown + + +@pytest.fixture(scope="module") +def url() -> Iterator[str]: + base, stop = _serve() + yield base + stop() + + +def _record(url: str, out: Path) -> Path: + def drive(page: Any, pump: Any) -> None: + def settle() -> None: + for _ in range(12): + pump() + + page.wait_for_selector("[name=field_summary]", state="visible") + page.locator("[name=field_summary]").click() + settle() + page.keyboard.type("Printer jams on tray 2") + settle() + page.locator("[name=field_description]").click() + settle() + page.keyboard.type("Seen twice today") + settle() + page.locator("[name=submit]").click() + settle() + assert page.evaluate("window.created") == ["Printer jams on tray 2"] + + recording = record_interactive( + url, + out / "rec", + headless=True, + script=drive, + param_fields=("field_summary", "field_description"), + ) + compile_recording(recording, out / "bundle", name="newticket") + return out / "bundle" + + +@pytest.fixture(scope="module") +def bundle(url: str, tmp_path_factory: pytest.TempPathFactory) -> Path: + return _record(url, tmp_path_factory.mktemp("create-form")) + + +@pytest.fixture(scope="module") +def formattr_bundle(url: str, tmp_path_factory: pytest.TempPathFactory) -> Path: + return _record(f"{url}?v=formattr", tmp_path_factory.mktemp("create-formattr")) + + +def _replay(url: str, bundle: Path, run_dir: Path) -> tuple[Any, list]: + from openadapt_flow.backends.playwright_backend import PlaywrightBackend + from openadapt_flow.runtime import Replayer + + with sync_api.sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + page = browser.new_page(viewport={"width": 1280, "height": 800}) + page.goto(url) + page.wait_for_selector("[name=field_summary]", state="visible") + report = Replayer(PlaywrightBackend(page)).run( + Workflow.load(bundle), + params={ + "field_summary": "Scanner offline on floor 3", + "field_description": "Since this morning", + }, + bundle_dir=bundle, + run_dir=run_dir, + ) + created = page.evaluate("window.created") + finally: + browser.close() + return report, created + + +def test_the_create_button_is_armed_by_its_form(bundle: Path) -> None: + workflow = Workflow.load(bundle) + save = workflow.steps[-1] + assert save.risk == "irreversible" + assert save.anchor is not None and save.anchor.form_identity is not None + assert save.anchor.form_identity.filled_fields == 2 + assert save.anchor.form_identity.submit_role == "form_submit" + assert save.identity_armed is True + assert is_identity_armed(save) + # Counts and tokens only: nothing typed is stored with it. + stored = json.dumps(save.anchor.form_identity.model_dump()) + assert "Printer" not in stored and "Seen" not in stored + + +def test_replay_with_new_values_creates_the_new_ticket( + url: str, bundle: Path, tmp_path: Path +) -> None: + report, created = _replay(url, bundle, tmp_path / "run") + assert report.success, [(r.step_id, r.error) for r in report.results] + assert created == ["Scanner offline on floor 3"] + save = report.results[-1] + assert save.identity is not None + assert (save.identity.mode, save.identity.status) == ("form", "verified") + + +def test_a_field_changed_after_typing_halts_before_the_click( + url: str, bundle: Path, tmp_path: Path +) -> None: + report, created = _replay(f"{url}?v=tamper", bundle, tmp_path / "run") + assert not report.success + assert created == [] + save = next(r for r in report.results if r.identity and r.identity.mode == "form") + assert save.identity.status == "mismatch" + assert "don't hold exactly the value this run typed" in save.identity.observed + + +def test_a_button_of_another_form_halts_before_the_click( + url: str, bundle: Path, tmp_path: Path +) -> None: + report, created = _replay(f"{url}?v=split", bundle, tmp_path / "run") + assert not report.success + assert created == [] + save = next(r for r in report.results if r.identity and r.identity.mode == "form") + assert save.identity.status == "mismatch" + assert "not in the form that owns the filled fields" in save.identity.observed + + +def test_a_controlled_input_that_reformats_halts_before_the_click( + url: str, bundle: Path, tmp_path: Path +) -> None: + """Every keystroke reformats the field. The typed-value read-back on the + summary step stops the run first; whatever notices first, nothing is + submitted.""" + + report, created = _replay(f"{url}?v=reformat", bundle, tmp_path / "run") + assert not report.success + assert created == [] + checked = [r for r in report.results if r.identity and r.identity.mode == "form"] + if checked: + assert checked[-1].identity.status == "mismatch" + assert "don't hold exactly the value" in checked[-1].identity.observed + + +def test_a_value_reformatted_on_blur_halts_at_the_form_check( + url: str, bundle: Path, tmp_path: Path +) -> None: + """The read-back after typing passes; the field is reformatted when focus + moves on. The form check before the save catches it.""" + + report, created = _replay(f"{url}?v=blurformat", bundle, tmp_path / "run") + assert not report.success + assert created == [] + save = next(r for r in report.results if r.identity and r.identity.mode == "form") + assert save.identity.status == "mismatch" + assert "don't hold exactly the value this run typed" in save.identity.observed + + +def test_a_button_outside_the_form_with_form_attribute_is_armed( + url: str, formattr_bundle: Path, tmp_path: Path +) -> None: + save = Workflow.load(formattr_bundle).steps[-1] + assert save.anchor is not None and save.anchor.form_identity is not None + assert save.anchor.form_identity.submit_role == "form_submit" + + report, created = _replay(f"{url}?v=formattr", formattr_bundle, tmp_path / "run") + assert report.success, [(r.step_id, r.error) for r in report.results] + assert created == ["Scanner offline on floor 3"] + assert report.results[-1].identity.status == "verified" + + +def test_a_portal_button_with_no_form_owner_is_not_armed_and_halts( + url: str, formattr_bundle: Path, tmp_path: Path +) -> None: + # Recorded on the portal page: nothing ties the button to the form, so + # the save gets no form identity and stays unarmed (a policy that + # refuses unarmed saves refuses it). + portal = _record(f"{url}?v=portal", tmp_path / "portal") + save = Workflow.load(portal).steps[-1] + assert save.anchor is not None and save.anchor.form_identity is None + assert not is_identity_armed(save) + + # A program recorded with form= meets the portal page: halt before the + # click. + report, created = _replay(f"{url}?v=portal", formattr_bundle, tmp_path / "run") + assert not report.success + assert created == [] + checked = [r for r in report.results if r.identity and r.identity.mode == "form"] + assert checked and checked[-1].identity.status == "mismatch" + assert ( + "not in the form that owns the filled fields" in checked[-1].identity.observed + ) + + +@pytest.fixture(scope="module") +def parent_bundle(url: str, tmp_path_factory: pytest.TempPathFactory) -> Path: + return _record(f"{url}?v=parent_ok", tmp_path_factory.mktemp("create-parent")) + + +@pytest.fixture(scope="module") +def select_bundle(url: str, tmp_path_factory: pytest.TempPathFactory) -> Path: + return _record(f"{url}?v=select_ok", tmp_path_factory.mktemp("create-select")) + + +def _halted_by_form(report: Any, created: list, expected: str) -> None: + assert not report.success + assert created == [] + checked = [r for r in report.results if r.identity and r.identity.mode == "form"] + assert checked, [(r.step_id, r.error) for r in report.results] + assert checked[-1].identity.status == "mismatch" + assert expected in checked[-1].identity.observed + + +@pytest.mark.parametrize( + "variant, expected", + [ + ("edit", "submits to a different address"), + ("action_swap", "submits to a different address"), + ("late_hidden", "different set of fields"), + ], +) +def test_a_form_that_is_not_the_demonstrated_one_halts( + url: str, bundle: Path, tmp_path: Path, variant: str, expected: str +) -> None: + report, created = _replay(f"{url}?v={variant}", bundle, tmp_path / "run") + _halted_by_form(report, created, expected) + + +def test_a_hidden_parent_on_another_record_halts( + url: str, parent_bundle: Path, tmp_path: Path +) -> None: + save = Workflow.load(parent_bundle).steps[-1] + assert save.anchor is not None and save.anchor.form_identity is not None + stored = json.dumps(save.anchor.form_identity.model_dump()) + assert '"7"' not in stored and "pid" in stored # a digest, not the value + + report, created = _replay(f"{url}?v=parent_ok", parent_bundle, tmp_path / "ok") + assert report.success, [(r.step_id, r.error) for r in report.results] + assert created == ["Scanner offline on floor 3"] + + report, created = _replay(f"{url}?v=parent_wrong", parent_bundle, tmp_path / "bad") + _halted_by_form(report, created, "'pid' field, which this run did not type into") + + +def test_a_select_left_on_another_default_halts( + url: str, select_bundle: Path, tmp_path: Path +) -> None: + report, created = _replay(f"{url}?v=select_ok", select_bundle, tmp_path / "ok") + assert report.success, [(r.step_id, r.error) for r in report.results] + + report, created = _replay(f"{url}?v=select_wrong", select_bundle, tmp_path / "bad") + _halted_by_form(report, created, "'pid' field, which this run did not type into") + + +def test_a_click_that_submits_another_form_is_cancelled( + url: str, bundle: Path, tmp_path: Path +) -> None: + report, created = _replay(f"{url}?v=click_other", bundle, tmp_path / "run") + assert not report.success + assert created == [] + save = report.results[-1] + assert save.identity is not None and save.identity.status == "verified" + assert "submitted a different form" in (save.error or "") + + +@pytest.mark.parametrize("variant", ["csrf", "rotating"]) +def test_a_rotating_token_still_verifies( + url: str, tmp_path: Path, variant: str +) -> None: + """A per-session token (named like one) and a hidden value that changed + while the demonstration was typed are not compared, so a new page load + with a new token still verifies, and the token's value is never kept.""" + + bundle = _record(f"{url}?v={variant}", tmp_path / "rec") + save = Workflow.load(bundle).steps[-1] + assert save.anchor is not None and save.anchor.form_identity is not None + assert save.anchor.form_identity.exempt == ( + ("__FORM_TOKEN",) if variant == "csrf" else ("draft_rev",) + ) + report, created = _replay(f"{url}?v={variant}", bundle, tmp_path / "run") + assert report.success, [(r.step_id, r.error) for r in report.results] + assert created == ["Scanner offline on floor 3"] + assert report.results[-1].identity.status == "verified" + + +def test_a_keyed_remount_halts_a_known_limit( + url: str, bundle: Path, tmp_path: Path +) -> None: + """A React-style keyed remount replaces a typed field with a new node, even + with the same value. The new node isn't one the run typed into, so the run + halts: a false halt, and the safe direction (LIMITS.md).""" + + report, created = _replay(f"{url}?v=remount", bundle, tmp_path / "run") + _halted_by_form(report, created, "typed into 1 field(s)")