diff --git a/docs/testing.md b/docs/testing.md
index 804ecb635e..0d2caad5fa 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -93,6 +93,12 @@ installed Chrome-family browser. An optional script argument selects an already
installed package's `scripts/rea.mjs` entry point for packaged-artifact checks.
The complete `verify:browser` lane includes these same checks.
+After building, `verify:browser:dom` checks empty and HTML-whitespace form
+destinations against a native Chrome DOM-property oracle through CLI and stdio
+MCP. It requires `REA_BROWSER_EXECUTABLE` and accepts an optional installed REA
+entrypoint. The full browser lane includes the same public-adapter assertions
+before other fixtures navigate the selected page.
+
`verify:browser:scripts` checks active script capture → exact-byte export →
existing static JavaScript analysis through CLI and stdio MCP, including
manifest readback, competing query variants, and resolved relative imports.
diff --git a/package.json b/package.json
index cfc5e283ea..906eb218f2 100644
--- a/package.json
+++ b/package.json
@@ -132,6 +132,7 @@
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
"verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs",
"verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs",
+ "verify:browser:dom": "node scripts/verify-browser-dom-destinations.mjs",
"verify:browser:network": "npm run build:cached && node scripts/verify-browser-network.mjs",
"verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs",
"verify:inspector": "npm run build:cached && node scripts/verify-real-javascript-runtime-observation.mjs",
diff --git a/scripts/fixtures/browser-verifier-site.mjs b/scripts/fixtures/browser-verifier-site.mjs
index 3dbf8f5183..30a7b36e76 100644
--- a/scripts/fixtures/browser-verifier-site.mjs
+++ b/scripts/fixtures/browser-verifier-site.mjs
@@ -57,7 +57,7 @@ export async function startBrowserVerifierSite() {
"rea-app-cookie=app-fixture; Path=/app; SameSite=Lax",
]);
response.end(
- '
REA browser verifier Browser evidence Guide Verify ',
+ 'REA browser verifier Browser evidence Guide Verify ',
);
});
const webSockets = new WebSocketServer({ noServer: true });
diff --git a/scripts/lib/browser-dom-destinations-e2e.mjs b/scripts/lib/browser-dom-destinations-e2e.mjs
new file mode 100644
index 0000000000..2a7006b948
--- /dev/null
+++ b/scripts/lib/browser-dom-destinations-e2e.mjs
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+
+import { parseEvidence } from "../../dist/domain/evidence.js";
+import { assertFormDestinations } from "./browser-verifier-assertions.mjs";
+import { mcpTextValue } from "./mcp-verifier-results.mjs";
+
+/** Verify the real fixture's form destinations through CLI and stdio MCP. */
+export async function verifyBrowserDomDestinations(
+ attachment,
+ entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
+ documentUrl,
+) {
+ const env = { ...process.env, REA_LOG_LEVEL: "silent" };
+ const { stdout } = await promisify(execFile)(
+ process.execPath,
+ [
+ entrypoint,
+ "inspect-web-page",
+ attachment.cdp_endpoint,
+ attachment.target_id,
+ "--observation-ms",
+ "100",
+ "--json",
+ ],
+ { env, timeout: 60000, maxBuffer: 32 * 1024 * 1024 },
+ );
+ assertFormDestinations(
+ parseEvidence(JSON.parse(stdout)).normalized_result,
+ documentUrl,
+ );
+ const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env,
+ stderr: "pipe",
+ });
+ const client = new Client({ name: "browser-dom-destinations", version: "1" });
+ try {
+ await client.connect(transport);
+ const result = await client.callTool({
+ name: "inspect_web_page",
+ arguments: { ...attachment, observation_ms: 100 },
+ });
+ assert.notEqual(result.isError, true, mcpTextValue(result));
+ assertFormDestinations(
+ parseEvidence(JSON.parse(mcpTextValue(result)).evidence)
+ .normalized_result,
+ documentUrl,
+ );
+ return { cli: true, stdio_mcp: true, current_document_destinations: 4 };
+ } finally {
+ await client.close();
+ await transport.close();
+ }
+}
diff --git a/scripts/lib/browser-verifier-assertions.mjs b/scripts/lib/browser-verifier-assertions.mjs
index ad22ff7122..7c26e1d987 100644
--- a/scripts/lib/browser-verifier-assertions.mjs
+++ b/scripts/lib/browser-verifier-assertions.mjs
@@ -87,6 +87,7 @@ function assertObservationPrivacy(result) {
}
function assertObservationMetadata(result, origin) {
+ assertFormDestinations(result);
if (
!result.metadata.dom_urls.some(
({ attribute, url }) =>
@@ -121,6 +122,23 @@ function assertObservationMetadata(result, origin) {
throw new Error("Real Chrome safe response metadata was missing");
}
+/** Assert the fixture's empty form destinations retain the current document URL. */
+export function assertFormDestinations(
+ result,
+ documentUrl = result.target.url,
+) {
+ const destinations = result.metadata.dom_urls.filter(
+ ({ attribute }) => attribute === "action" || attribute === "formaction",
+ );
+ if (
+ destinations.length !== 4 ||
+ destinations.some(({ url }) => url !== documentUrl)
+ )
+ throw new Error(
+ `Real Chrome form destinations did not use the current document URL: ${JSON.stringify({ expected: documentUrl, target: result.target.url, destinations })}`,
+ );
+}
+
/** Assert real-browser static bundle and source-map findings. */
export function assertBundleAnalysis(result) {
if (
diff --git a/scripts/verify-browser-dom-destinations.mjs b/scripts/verify-browser-dom-destinations.mjs
new file mode 100644
index 0000000000..7390caf11a
--- /dev/null
+++ b/scripts/verify-browser-dom-destinations.mjs
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { chromium } from "playwright-core";
+import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
+import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
+
+const executable = process.env.REA_BROWSER_EXECUTABLE;
+if (!executable)
+ throw new Error("verify:browser:dom requires REA_BROWSER_EXECUTABLE");
+const profile = await mkdtemp(join(tmpdir(), "rea-browser-dom-"));
+const site = await startBrowserVerifierSite();
+let context;
+try {
+ context = await chromium.launchPersistentContext(profile, {
+ executablePath: executable,
+ headless: true,
+ args: ["--remote-debugging-port=0"],
+ });
+ const page = context.pages()[0] ?? (await context.newPage());
+ await page.goto(`${site.origin}/app?selected=fixture#section`);
+ const native = await page.evaluate(() =>
+ [
+ ...document.querySelectorAll("form,button[formaction],input[formaction]"),
+ ].map((element) =>
+ element.tagName === "FORM" ? element.action : element.formAction,
+ ),
+ );
+ assert.deepEqual(native, Array(4).fill(page.url()));
+ const session = await context.newCDPSession(page);
+ const { targetInfo } = await session.send("Target.getTargetInfo");
+ await session.detach();
+ const [port] = (
+ await readFile(join(profile, "DevToolsActivePort"), "utf8")
+ ).split("\n");
+ const result = await verifyBrowserDomDestinations(
+ {
+ cdp_endpoint: `http://127.0.0.1:${port}`,
+ target_id: targetInfo.targetId,
+ },
+ process.argv[2],
+ page.url(),
+ );
+ process.stdout.write(
+ `${JSON.stringify({ ...result, browser: context.browser().version(), native_oracle: true, verified: true })}\n`,
+ );
+} finally {
+ await context?.close();
+ await site.close();
+ await rm(profile, { recursive: true, force: true });
+}
diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs
index 0d7e33286d..e68e5b15ba 100644
--- a/scripts/verify-real-browser.mjs
+++ b/scripts/verify-real-browser.mjs
@@ -37,6 +37,7 @@ import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs";
import { verifyPopupEventCoverage } from "./lib/browser-popup-e2e.mjs";
import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs";
import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs";
+import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
@@ -98,6 +99,10 @@ try {
);
if (!observed.ok) throw observed.error;
assertObservation(observed.value, site.origin);
+ const domDestinations = await verifyBrowserDomDestinations({
+ cdp_endpoint: endpoint,
+ target_id: target,
+ });
const serialized = JSON.stringify(observed.value);
if (
!observed.value.target.url.includes("startup=browser-secret-value") ||
@@ -352,6 +357,7 @@ try {
popupEvents,
networkEvidence,
scriptExport,
+ domDestinations,
browserScenarioCli: true,
browserScenarioAttachCleanup: "disconnected-external",
browserScenarioLaunchCleanup: "terminated-owned-process",
diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts
index d7ffd3e1cb..7b3dc1868b 100644
--- a/src/browser/CdpCaptureDocuments.baseUrl.test.ts
+++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts
@@ -113,3 +113,81 @@ describe("DOM metadata document base URLs", () => {
expect(result.agentHints).toEqual([]);
});
});
+
+describe("empty form destinations with a document base URL", () => {
+ it.each([
+ { nodeName: "FORM", attribute: "action" },
+ { nodeName: "BUTTON", attribute: "formaction" },
+ { nodeName: "INPUT", attribute: "formaction" },
+ ])(
+ "resolves an empty $nodeName $attribute to the document",
+ ({ nodeName, attribute }) => {
+ const pageUrl = `${documentUrl}?selected=fixture#section`;
+ const baseUrl = `${origin}/assets/`;
+ const value = snapshot(baseUrl, "/assets/", pageUrl, "");
+ value.strings[6] = nodeName;
+ value.strings[14] = attribute;
+ value.strings[15] = "";
+ const result = capture(value);
+ expect(result.urls[3]).toMatchObject({
+ attribute,
+ url: pageUrl,
+ destination_scope: "approved",
+ });
+ expect(result.urls[1]?.url).toBe(baseUrl);
+ },
+ );
+
+ it("keeps an empty form action approved when the document base is foreign", () => {
+ const foreign = "https://cdn.example.test/assets/";
+ const value = snapshot(foreign, foreign);
+ value.strings[15] = "";
+ expect(capture(value).urls[3]).toMatchObject({
+ url: documentUrl,
+ destination_scope: "approved",
+ });
+ });
+
+ it.each([" ", "\t\r\n\f"])(
+ "resolves an HTML-whitespace-only form action to the document: %j",
+ (action) => {
+ const baseUrl = `${origin}/assets/`;
+ const value = snapshot(baseUrl);
+ value.strings[15] = action;
+ expect(capture(value).urls[3]?.url).toBe(documentUrl);
+ },
+ );
+
+ it.each(["BUTTON", "INPUT"])(
+ "retains document-base resolution for a whitespace-only %s formaction",
+ (nodeName) => {
+ const baseUrl = `${origin}/assets/`;
+ const value = snapshot(baseUrl);
+ value.strings[6] = nodeName;
+ value.strings[14] = "formaction";
+ value.strings[15] = " ";
+ expect(capture(value).urls[3]?.url).toBe(baseUrl);
+ },
+ );
+
+ it.each([
+ { value: "\u000b", suffix: "" },
+ { value: "\u00a0", suffix: "%C2%A0" },
+ ])(
+ "does not treat non-HTML whitespace as an empty form action: $value",
+ ({ value: action, suffix }) => {
+ const baseUrl = `${origin}/assets/`;
+ const value = snapshot(baseUrl);
+ value.strings[15] = action;
+ expect(capture(value).urls[3]?.url).toBe(`${baseUrl}${suffix}`);
+ },
+ );
+
+ it("does not apply form semantics to an action attribute on another element", () => {
+ const baseUrl = `${origin}/assets/`;
+ const value = snapshot(baseUrl);
+ value.strings[6] = "DIV";
+ value.strings[15] = "";
+ expect(capture(value).urls[3]?.url).toBe(baseUrl);
+ });
+});
diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts
index 53c608053a..95749beeb0 100644
--- a/src/browser/CdpCaptureDocuments.ts
+++ b/src/browser/CdpCaptureDocuments.ts
@@ -194,6 +194,7 @@ export const captureDom = (
attributes: attributeIndexes,
// The BASE element itself resolves against the document fallback URL.
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
+ documentUrl,
nodeIndex,
nodeName,
allowedOrigins,
@@ -363,6 +364,7 @@ interface DomMetadataOptions {
readonly strings: readonly string[];
readonly attributes: readonly number[];
readonly baseUrl: string;
+ readonly documentUrl: string;
readonly nodeIndex: number;
readonly nodeName: string;
readonly allowedOrigins: ReadonlySet;
@@ -374,8 +376,15 @@ const domMetadata = (
readonly urls: WebPageInspection["metadata"]["dom_urls"];
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
} => {
- const { strings, attributes, baseUrl, nodeIndex, nodeName, allowedOrigins } =
- options;
+ const {
+ strings,
+ attributes,
+ baseUrl,
+ documentUrl,
+ nodeIndex,
+ nodeName,
+ allowedOrigins,
+ } = options;
const pairs = new Map();
for (let index = 0; index + 1 < attributes.length; index += 2) {
const name = indexedString(strings, attributes[index]).toLowerCase();
@@ -386,7 +395,21 @@ const domMetadata = (
for (const attribute of domUrlAttributes) {
const value = pairs.get(attribute);
if (value === undefined) continue;
- const destination = domDestination(value, baseUrl, allowedOrigins);
+ const tagName = nodeName.toLowerCase();
+ // Chrome strips HTML whitespace for FORM.action, while submit controls
+ // use the document URL only for an exactly empty formaction attribute.
+ const usesDocumentUrl =
+ (tagName === "form" &&
+ attribute === "action" &&
+ /^[\t\n\f\r ]*$/u.test(value)) ||
+ ((tagName === "button" || tagName === "input") &&
+ attribute === "formaction" &&
+ value === "");
+ const destination = domDestination(
+ usesDocumentUrl ? documentUrl : value,
+ baseUrl,
+ allowedOrigins,
+ );
urls.push({
node_index: nodeIndex,
attribute,
diff --git a/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts
index 8dda46cf9e..8b040144ad 100644
--- a/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts
+++ b/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts
@@ -115,3 +115,25 @@ describe("JavaScript semantic values for properties unaffected by a mutation", (
).toBe("unknown");
});
});
+
+describe("property mutations through TypeScript satisfies aliases", () => {
+ it.each([
+ 'const shared = { mode: "initial" }; const alias = shared satisfies { mode: string }; alias.mode = "updated"; return shared.mode;',
+ 'const shared = { nested: { mode: "initial" } }; const alias = shared satisfies { nested: { mode: string } }; alias.nested.mode = "updated"; return shared.nested.mode;',
+ 'const shared = { mode: "initial" }; const alias = (shared satisfies { mode: string }) as { mode: string }; delete alias.mode; return shared.mode;',
+ "const shared = [1]; const alias = shared satisfies number[]; alias[0]++; return shared[0];",
+ ])("keeps the mutated value unknown: %s", (body) => {
+ expect(resultValue(body)?.status).toBe("unknown");
+ });
+
+ it("retains the untouched property through a satisfies alias", () => {
+ expect(
+ resultValue(`
+ const shared = { mode: "initial", token: "TOKEN" };
+ const alias = shared satisfies { mode: string; token: string };
+ alias.mode = "updated";
+ return shared.token;
+ `),
+ ).toEqual({ status: "literal", value: "TOKEN" });
+ });
+});
diff --git a/src/domain/javascriptSemanticMemberMutations.ts b/src/domain/javascriptSemanticMemberMutations.ts
index 4991f23e7e..e168b20468 100644
--- a/src/domain/javascriptSemanticMemberMutations.ts
+++ b/src/domain/javascriptSemanticMemberMutations.ts
@@ -125,6 +125,7 @@ const referencedValues = (
if (
t.isTSAsExpression(node) ||
t.isTSTypeAssertion(node) ||
+ t.isTSSatisfiesExpression(node) ||
t.isTSNonNullExpression(node)
)
return [{ node: node.expression, path }];