diff --git a/README.md b/README.md index c13f668..ca0eae0 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,14 @@ against real macOS TCC apps was **not run this round**, see real launch is owner opt-in). - **Browser** — read-only Safari/Chrome tabs, bookmarks, history (per-browser graceful degradation). +- **Browser control** (`browser-control`) — drive a real Chrome over the DevTools + Protocol, no Puppeteer/Playwright dependency. The owner's decision is **which + browser**: a fresh empty profile (`launch`, the default — no cookies, no sessions) or + the browser they are logged into (`attach`, opt-in). The **page** surface is fully + open, arbitrary JavaScript included, because inside an authorized page `click`+`type` + already equal full user agency; what is withheld is the **browser-global** half of CDP + (`Target`, `Browser`, `Storage`, the cookie jar), which is what keeps a domain + allowlist meaningful. File upload is jailed to an owner-named directory. - **Workspace** (`workspace`) — one authorized working directory as a path-confined filesystem: read (`workspace.{list,read}`) **and** write (`workspace.write` → pends). - **Claude Code** (`claudecode`) — headless Claude Code under macOS `sandbox-exec` @@ -288,7 +296,7 @@ Plexus carries **two independent version numbers**, and the distinction matters: | | What it is | How it moves | Who depends on it | |---|---|---|---| -| **Software version** (`PLEXUS_VERSION`, e.g. `0.8.1`) | the **product** release — the gateway, desktop app, sources, UI | **fast** — every feature/fix bumps it | nobody on the wire; it's informational (shown in the admin UI as `running · v0.8.1`) | +| **Software version** (`PLEXUS_VERSION`, e.g. `0.9.0`) | the **product** release — the gateway, desktop app, sources, UI | **fast** — every feature/fix bumps it | nobody on the wire; it's informational (shown in the admin UI as `running · v0.9.0`) | | **Protocol version** (`PLEXUS_PROTOCOL_VERSION`, `0.1.3`) | the **agent-facing wire contract** — the shapes of discover / handshake / grant / invoke | **rarely** — frozen, **additive-only** (a new optional field bumps the patch) | **agents** integrate against *this*, never the software version | They are **decoupled by design**: the product can ship `0.6 → 0.7 → 1.0 …` while the @@ -297,7 +305,7 @@ that integrated at protocol `0.1.0` keeps working across every software release needs to care when the *protocol* version changes (and even then, additively). The admin UI surfaces both, distinctly: `running · v · protocol `. -> Tags/releases track the **software** version (`v0.8.1`). The protocol version lives +> Tags/releases track the **software** version (`v0.9.0`). The protocol version lives > in code (`@plexus/protocol`) and `.well-known/plexus`, and bumps on its own schedule. --- diff --git a/docs/design/browser-control.md b/docs/design/browser-control.md new file mode 100644 index 0000000..4da2a59 --- /dev/null +++ b/docs/design/browser-control.md @@ -0,0 +1,176 @@ +# Browser control + +> Status: **built and shipped in 0.9.0**, verified end to end against Chrome 151. The read-only +> `browser` source (tabs / bookmarks / history) is unrelated and unchanged; this is a new, +> execute-class source. + +## What the research settles + +**Chrome no longer needs an extension to be attached to.** Since **M144** a external process can +request a remote-debugging session against the user's *already running* Chrome: the user enables +it once at `chrome://inspect/#remote-debugging`, and thereafter **Chrome itself shows a permission +dialog per connection** and displays the "Chrome is being controlled by automated test software" +banner for the life of the session. M146 adds a settings toggle. The machine this is being built +on runs **Chrome 151**, so the flow is available today, on stable. + +That kills the main reason to ship a browser extension. An extension (plus a native-messaging +host to reach it) is a large surface to build, sign, distribute and keep alive, and it would +duplicate a consent flow Chrome now owns. Its one *remaining* advantage is picking individual +tabs without enabling global remote debugging — recorded as a seam below, not built. + +**Chrome's consent is all-or-nothing.** The permission dialog authorizes *the browser*, not a set +of sites. Chrome exposes no per-tab or per-origin scoping. So the boundary the owner actually +wants — "this agent may touch GitHub tabs, nothing else" — cannot come from Chrome. + +It has to come from Plexus. Which is the whole point of Plexus. + +## The shape + +One source, `browser-control`, and **one capability surface**. The two modes differ only in +where the CDP endpoint comes from: + +| mode | endpoint | what the agent can reach | +|---|---|---| +| **`launch`** (default) | Plexus spawns Chrome with `--remote-debugging-port` on an **ephemeral port** and a **separate `--user-data-dir`** | a clean profile — no cookies, no logged-in sessions | +| **`attach`** (owner opt-in) | the user's running Chrome, gated by Chrome's own dialog | **every session that browser is logged into** | + +`launch` is the safe default and covers ordinary "go read this page" work. `attach` is the sharp +one and is an explicit owner decision, exactly like `Real launch` on the exec sources. + +**No new dependency.** CDP is JSON over a WebSocket; Bun has both `fetch` and `WebSocket` natively. +Puppeteer/Playwright would each drag in a browser download and a large dependency tree to give us +an ergonomics layer we do not need for a handful of tools. + +## The decision that carries the weight + +**Which browser the agent gets.** A fresh empty profile that is nobody, or the browser the owner +is logged into. That is the choice that changes the blast radius; nearly everything else is +friction dressed as safety. Inside an authorized page `click` + `type` already equal full user +agency — it can order, send, delete, change settings — so withholding `evaluate` on top of that +prevents no real harm and only makes the capability worse than the alternatives an owner would +reach for instead. **The page surface is therefore open.** + +What is withheld is the part of CDP that does not belong to any page. See "The one split that +matters" below; it is what keeps the domain boundary from being a slogan. + +## The boundary — the part that is ours + +Every call resolves to a **target URL**, and the source enforces an owner-set **domain allowlist** +against the URL that will actually be acted on — parsed server-side from the real target, never +from a field the agent declares. Three rules make it hold: + +1. **An empty list means refuse — for the browser that has something to lose.** Against the + owner's own browser (`attach`), unset is inert, not open. Against a browser Plexus launched on + an empty profile there are no cookies and no sessions to wall off, so unset means the open + web: a wall around a browser that is nobody protects nothing and only breaks the first call. + The `http`/`https` scheme rule applies either way, so "the whole web" never means the local + disk or Chrome's own settings pages. +2. **An entry authorizes its domain, including subdomains.** `deepseek.com` covers + `www.deepseek.com`, because a site whose apex redirects to `www` is one site to the owner who + typed it. The match is on the parsed host at a **dot boundary**, so `deepseek.com.evil.com` + and `evildeepseek.com` are outside it; an IP entry matches exactly, since suffix logic on + numbers would let `168.1.5` admit `192.168.1.5`; the scheme must match, so authorizing a site + never implies its plaintext form. A single-label entry (`com`) is dropped. There is no + public-suffix list, so an entry that is not a registrable domain (`co.uk`) is an owner + foot-gun, not a guarded case. +3. **The tab's CURRENT origin is re-checked before every act**, in both modes, including calls + that reuse a held debugging socket. A tab that was allowed when it was on `github.com` is not + allowed after it navigates to `mail.google.com`. Reuse is a transport optimization; it never + carries a verdict forward. + +This composes with, and does not replace, the existing `ScopeConstraint` machinery: an owner can +narrow a grant further per agent (`{field:"url", op:"prefix", …}`), enforced at the same single +invoke chokepoint, fail-closed. The source-level allowlist is the floor; a constraint can only +subtract. + +## Verbs and sensitivity + +| capability | verb | note | +|---|---|---| +| `browser-control.tabs.list` | read | which tabs are controllable, domain-filtered | +| `browser-control.page.read` | read | title, url and the rendered text of the current page | +| `browser-control.page.screenshot` | read | viewport image, or the whole page with `fullPage` | +| `browser-control.page.elements` | read | interactive elements with working selectors; passwords report length only | +| `browser-control.page.scroll` | read | move the viewport; reports `atBottom` | +| `browser-control.page.wait` | read | block for a selector, a string, or loading to finish | +| `browser-control.frames.list` | read | embedded frames, judged on their OWN domain | +| `browser-control.page.navigate` | execute | the domain gate's primary subject | +| `browser-control.page.click` / `.type` | execute | act on a selector read off the page | +| `browser-control.page.press` | execute | a real key event; Enter can submit | +| `browser-control.page.evaluate` | execute | arbitrary JavaScript, as the page | +| `browser-control.page.cdp` | execute | any page-scoped CDP command, verbatim | +| `browser-control.page.upload` | execute | attach a file, only from the owner's upload directory | + +`execute` means per-use approval by default (ADR-5) — the agent cannot lift it. Under `attach` +even the `read` verbs are high-sensitivity, because the page may be an authenticated one. + +Scroll and wait are reads because neither dispatches an action on the site's behalf: they change +what is visible, or how long we look, and cannot submit, follow or activate anything. + +## Honest risk + +`attach` mode is the sharpest thing Plexus would expose: it reaches the user's authenticated +web. That risk is inherent to the feature, and the mitigations are the reason to route it through +Plexus rather than let an agent hold the CDP socket directly — owner opt-in, a fail-closed origin +allowlist enforced on the real URL, per-use approval on every mutating verb, the whole call +audited, plus Chrome's own dialog and its visible automation banner. + +The residual that no design here removes: anything reachable *without* re-authentication inside an +allowed domain is reachable by an approved call. The allowlist bounds which sites, not which pages +within a site — and now that an entry covers subdomains, it bounds them one domain at a time. + +Plexus puts back what it takes: the debugging sockets and the tabs it opened are closed on +shutdown, so an agent's browsing does not accumulate windows in the user's Chrome. The limit is +that a session's tab lives until then — there is no per-session teardown hook to close it sooner. + +## What is NOT mapped + +CDP is enormous; the surface here is twelve verbs. Deliberately absent: **arbitrary +`Runtime.evaluate`**, which would make the origin gate decorative since a page can `fetch` +anywhere its own origin allows; **console and network inspection**, which read cross-origin +responses the gate never judged; **history back/forward**, which can land outside the allowlist +without a URL to gate on; and **cookie, storage and download** access, which is the authenticated +state itself rather than a view of it. Upload is present and download is not, because upload is +bounded by a directory the owner names while a download writes wherever the browser decides. Each is a separate decision, not an oversight. + +## Two failures that only a real page shows + +**A form field has no rendered text.** `page.read` returns what a human reads, which contains the +label "Email" and nothing that says the field is `input[name=em]`. An agent given only that verb +must invent selectors — the exact thing the skill tells it not to do. `page.elements` exists +because of this. + +**Writing `el.value` directly reports success and does nothing.** React installs its own `value` +setter, sees no change when the property is written behind its back, and swallows the event: the +field looks filled, the app's state stays empty, and the call returns `typed: true`. `page.type` +goes through the native prototype setter so the framework's tracker observes a real change, and +reports whether the field actually holds the value — without echoing it. + +## Frames, and why they are separate + +A cross-site ``, + { headers: { "content-type": "text/html" } }, + ), + }); + } + + it("an authorized page does NOT authorize what it embeds", async () => { + boot(); + const { deps: d } = deps(); + const b = new BrowserControlBridge(d, "fr1", browserControlEntries(), cfg([parentOrigin])); + await b.invoke({ id: BC_NAVIGATE_ID, input: { url: `${parentOrigin}/` } }, CTX); + await b.invoke({ id: BC_WAIT_ID, input: { selector: "iframe", timeoutMs: 10_000 } }, CTX); + + // The frame is on a domain the owner did not authorize, so it is neither listed… + const frames = (await b.invoke({ id: BC_FRAMES_ID, input: {} }, CTX)).output as { frames: unknown[] }; + expect(frames.frames).toEqual([]); + // …nor visible through the page: a page's selectors do not reach into another document. + const els = (await b.invoke({ id: BC_ELEMENTS_ID, input: {} }, CTX)).output as { + elements: Record[]; + }; + expect(els.elements.map((e) => e.name)).toEqual(["coupon"]); + }, 90_000); + + it("drives a frame, and uploads only from inside the owner's directory", async () => { + boot(); + writeFileSync(join(uploadDir, "invoice.txt"), "hello"); + const { deps: d } = deps(); + const b = new BrowserControlBridge(d, "fr2", browserControlEntries(), { + ...cfg([parentOrigin, frameOrigin]), + uploadDir, + }); + await b.invoke({ id: BC_NAVIGATE_ID, input: { url: `${parentOrigin}/` } }, CTX); + await b.invoke({ id: BC_WAIT_ID, input: { selector: "iframe", timeoutMs: 10_000 } }, CTX); + + const frames = (await b.invoke({ id: BC_FRAMES_ID, input: {} }, CTX)).output as { + frames: { targetId: string; url: string }[]; + }; + // Frames from every authorized tab are listed, the same way tabs.list spans the browser — + // an earlier test in this file may still have one open. + const mine = frames.frames.filter((f) => f.url.startsWith(frameOrigin)); + expect(mine.length).toBeGreaterThanOrEqual(1); + const targetId = mine[0]!.targetId; + + const typed = await b.invoke( + { id: BC_TYPE_ID, input: { targetId, selector: 'input[name="card"]', text: "4242" } }, + CTX, + ); + expect(typed.ok).toBe(true); + + // A file input's value is not settable from page JS — that is the protection that stops a + // website helping itself to your disk. The page itself witnesses the attachment. + const up = await b.invoke( + { id: BC_UPLOAD_ID, input: { targetId, selector: 'input[type="file"]', path: "invoice.txt" } }, + CTX, + ); + expect(up.ok).toBe(true); + expect((up.output as Record).fileName).toBe("invoice.txt"); + // The wire gets the file's NAME; where it lives on this machine is the owner's business. + expect(JSON.stringify(up.output)).not.toContain(uploadDir); + const framePage = (await b.invoke({ id: BC_READ_ID, input: { targetId } }, CTX)).output as { text: string }; + expect(framePage.text).toContain("picked: [invoice.txt]"); + + for (const path of ["../../etc/passwd", "/etc/passwd"]) { + const bad = await b.invoke({ id: BC_UPLOAD_ID, input: { targetId, selector: 'input[type="file"]', path } }, CTX); + expect(bad.ok).toBe(false); + expect(bad.error?.message).toContain("outside the owner's upload directory"); + } + }, 90_000); + + it("refuses every upload when the owner set no upload directory", async () => { + boot(); + const { deps: d } = deps(); + const b = new BrowserControlBridge(d, "fr3", browserControlEntries(), cfg([parentOrigin, frameOrigin])); + await b.invoke({ id: BC_NAVIGATE_ID, input: { url: `${frameOrigin}/` } }, CTX); + const res = await b.invoke( + { id: BC_UPLOAD_ID, input: { selector: 'input[type="file"]', path: "invoice.txt" } }, + CTX, + ); + expect(res.ok).toBe(false); + expect(res.error?.message).toContain("no upload directory is set"); + }, 90_000); +}); + +describe.skipIf(!RUNNABLE)("browser-control e2e — the page surface is open, the browser surface is not", () => { + it("runs arbitrary JavaScript in the page and returns its value", async () => { + const { deps: d } = deps(); + const b = new BrowserControlBridge(d, "ev", browserControlEntries(), cfg(["example.com"])); + await b.invoke({ id: BC_NAVIGATE_ID, input: { url: "https://example.com/" } }, CTX); + const res = await b.invoke( + { + id: BC_EVALUATE_ID, + input: { expression: "({ links: [...document.querySelectorAll('a')].map(a => a.href), h1: document.querySelector('h1')?.textContent })" }, + }, + CTX, + ); + expect(res.ok).toBe(true); + const out = res.output as { value: { links: string[]; h1: string } }; + expect(out.value.h1).toContain("Example"); + expect(out.value.links.length).toBeGreaterThan(0); + }, 60_000); + + it("takes a raw page-scoped CDP command, and refuses one that acts on the browser", async () => { + const { deps: d } = deps(); + const b = new BrowserControlBridge(d, "cdp", browserControlEntries(), cfg(["example.com"])); + await b.invoke({ id: BC_NAVIGATE_ID, input: { url: "https://example.com/" } }, CTX); + + const metrics = await b.invoke({ id: BC_CDP_ID, input: { method: "Performance.enable" } }, CTX); + expect(metrics.ok).toBe(true); + const dom = await b.invoke({ id: BC_CDP_ID, input: { method: "DOM.getDocument", params: { depth: 0 } } }, CTX); + expect(dom.ok).toBe(true); + expect((dom.output as { result: { root: { nodeId: number } } }).result.root.nodeId).toBeGreaterThan(0); + + // These are the two that would take the domain out of the question entirely. + for (const method of ["Target.attachToTarget", "Network.getAllCookies"]) { + const bad = await b.invoke({ id: BC_CDP_ID, input: { method } }, CTX); + expect(bad.ok).toBe(false); + expect(bad.error?.code).toBe("grant_required"); + } + // And this one is refused because it would be an UNGATED second door to navigation. + const nav = await b.invoke( + { id: BC_CDP_ID, input: { method: "Page.navigate", params: { url: "https://example.org/" } } }, + CTX, + ); + expect(nav.ok).toBe(false); + const still = await b.invoke({ id: BC_READ_ID, input: {} }, CTX); + expect(String((still.output as { url: string }).url)).toStartWith("https://example.com"); + }, 60_000); + + it("evaluate cannot read a page the owner did not authorize", async () => { + const { deps: d } = deps(); + const b = new BrowserControlBridge(d, "ev2", browserControlEntries(), cfg(["example.com"])); + await b.invoke({ id: BC_NAVIGATE_ID, input: { url: "https://example.com/" } }, CTX); + // JavaScript can send the tab anywhere — the browser owns navigation, not us. What the gate + // guarantees is that the agent cannot READ where it landed. + await b.invoke({ id: BC_EVALUATE_ID, input: { expression: "location.href = 'https://example.org/'" } }, CTX); + await new Promise((r) => setTimeout(r, 1500)); + const after = await b.invoke({ id: BC_EVALUATE_ID, input: { expression: "document.body.innerText" } }, CTX); + expect(after.ok).toBe(false); + expect(after.error?.message).toContain("example.org"); + }, 60_000); +}); diff --git a/tests/browser-control-origin-gate.test.ts b/tests/browser-control-origin-gate.test.ts new file mode 100644 index 0000000..fedf092 --- /dev/null +++ b/tests/browser-control-origin-gate.test.ts @@ -0,0 +1,182 @@ +/** + * The ORIGIN GATE — the security spine of browser control. + * + * Chrome's own remote-debugging consent is all-or-nothing (it authorizes the browser, not a set + * of sites), so this gate is where "which sites may this agent touch" is actually decided. The + * cases below are the ones that would matter if it were wrong: an unset allowlist must deny, and + * a hostile URL must not be able to look like an allowed one. + */ +import { describe, it, expect } from "bun:test"; +import { + judgeUrl, + normalizeAllowEntry, + normalizeAllowlist, + refusalMessage, +} from "@plexus/runtime/sources/browser-control/origin-gate.ts"; + +const ALLOW = ["https://github.com"]; + +describe("origin gate — unset means inert, never open", () => { + it("denies every URL when no allowlist is configured", () => { + for (const url of ["https://github.com/x", "https://example.com", "http://localhost:3000"]) { + const v = judgeUrl(url, []); + expect(v.allowed).toBe(false); + expect((v as { reason: string }).reason).toBe("no-allowlist"); + } + expect(judgeUrl("https://github.com", undefined).allowed).toBe(false); + }); + + it("an allowlist of only-unparseable entries is still empty (no accidental widening)", () => { + expect(normalizeAllowlist(["", " ", "not a url at all!!", "file:///etc"])).toEqual([]); + expect(judgeUrl("https://github.com", ["file:///etc"]).allowed).toBe(false); + }); +}); + +describe("origin gate — a hostile URL cannot impersonate an allowed one", () => { + it("refuses a suffix-extended host that a prefix match would accept", () => { + // `https://github.com.evil.com/` — the classic string-prefix defeat. + const v = judgeUrl("https://github.com.evil.com/pwn", ALLOW); + expect(v.allowed).toBe(false); + expect((v as { reason: string }).reason).toBe("not-allowed"); + expect((v as { origin?: string }).origin).toBe("https://github.com.evil.com"); + }); + + it("refuses userinfo that makes the real host look like the allowed one", () => { + // `https://github.com@evil.com/` — the host is evil.com; the allowed name is only userinfo. + const v = judgeUrl("https://github.com@evil.com/", ALLOW); + expect(v.allowed).toBe(false); + expect((v as { origin?: string }).origin).toBe("https://evil.com"); + }); + + it("does not let an https authorization imply its plaintext form", () => { + expect(judgeUrl("http://github.com/", ALLOW).allowed).toBe(false); + }); + + it("covers a port on the authorized host, and pins it when the owner named one", () => { + // A bare entry is a DOMAIN authorization, so any port on that host is in bounds… + expect(judgeUrl("https://github.com:8443/", ALLOW).allowed).toBe(true); + // …but an entry that names a port authorizes only that port. + expect(judgeUrl("https://github.com/", ["https://github.com:8443"]).allowed).toBe(false); + expect(judgeUrl("https://github.com:8443/", ["https://github.com:8443"]).allowed).toBe(true); + }); + + it("covers subdomains of an authorized host, at a dot boundary only", () => { + // What the owner means by "github.com" — the site, including its subdomains. + expect(judgeUrl("https://gist.github.com/x", ALLOW).allowed).toBe(true); + expect(judgeUrl("https://a.b.github.com/x", ALLOW).allowed).toBe(true); + // The boundary is a DOT: a host that merely ends in those characters is a different site. + expect(judgeUrl("https://evilgithub.com/x", ALLOW).allowed).toBe(false); + expect(judgeUrl("https://notgithub.com/x", ALLOW).allowed).toBe(false); + }); + + it("matches an address literal exactly — never by numeric suffix", () => { + // `192.168.1.5` ends with `.168.1.5`; suffix logic on numbers would admit a foreign host. + expect(judgeUrl("http://192.168.1.5/", ["http://168.1.5"]).allowed).toBe(false); + expect(judgeUrl("http://192.168.1.5/", ["http://192.168.1.5"]).allowed).toBe(true); + }); + + it("drops a single-label entry that would authorize a whole TLD", () => { + expect(normalizeAllowlist(["com"])).toEqual([]); + expect(judgeUrl("https://github.com/", ["com"]).allowed).toBe(false); + // `localhost` is a real single-label host an owner may legitimately mean. + expect(normalizeAllowlist(["http://localhost:3000"])).toEqual(["http://localhost:3000"]); + expect(judgeUrl("http://localhost:3000/x", ["http://localhost:3000"]).allowed).toBe(true); + }); + + it("allows the exact origin, on any path or query", () => { + for (const url of [ + "https://github.com", + "https://github.com/", + "https://github.com/nemori-ai/plexus/pull/21", + "https://github.com/search?q=a#frag", + ]) { + const v = judgeUrl(url, ALLOW); + expect(v.allowed).toBe(true); + expect((v as { origin: string }).origin).toBe("https://github.com"); + } + }); +}); + +describe("origin gate — non-web schemes stay out", () => { + it("refuses file, chrome, devtools and javascript targets even when an allowlist exists", () => { + for (const url of [ + "file:///Users/someone/.ssh/id_rsa", + "chrome://inspect/#remote-debugging", + "devtools://devtools/bundled/inspector.html", + "javascript:fetch('/')", + ]) { + const v = judgeUrl(url, ALLOW); + expect(v.allowed).toBe(false); + // `javascript:` and the rest are refused on scheme; none may be smuggled in by origin. + expect(["scheme", "unparseable", "not-allowed"]).toContain((v as { reason: string }).reason); + } + }); + + it("refuses chrome://inspect specifically — the page that governs remote debugging itself", () => { + expect(judgeUrl("chrome://inspect/#remote-debugging", ALLOW).allowed).toBe(false); + }); +}); + +describe("origin gate — owner input is normalized the way an owner would type it", () => { + it("accepts a bare host, a scheme'd host, and a trailing slash as the same origin", () => { + expect(normalizeAllowEntry("github.com")).toBe("https://github.com"); + expect(normalizeAllowEntry("https://github.com")).toBe("https://github.com"); + expect(normalizeAllowEntry("https://github.com/")).toBe("https://github.com"); + expect(normalizeAllowEntry(" github.com ")).toBe("https://github.com"); + }); + + it("defaults a bare host to https, not plaintext", () => { + expect(normalizeAllowEntry("example.com")).toBe("https://example.com"); + }); + + it("keeps an explicit http entry distinct (localhost dev is a real case)", () => { + expect(normalizeAllowEntry("http://localhost:3000")).toBe("http://localhost:3000"); + expect(judgeUrl("http://localhost:3000/app", ["http://localhost:3000"]).allowed).toBe(true); + }); + + it("dedupes entries that normalize to the same origin", () => { + expect(normalizeAllowlist(["github.com", "https://github.com/", "https://github.com"])).toEqual([ + "https://github.com", + ]); + }); +}); + +describe("origin gate — a refusal does not leak the owner's other sites", () => { + it("names the requested origin and the owner's remedy, never the allowlist", () => { + const v = judgeUrl("https://evil.com", ["https://github.com", "https://internal.corp"]); + const msg = refusalMessage(v as Extract); + expect(msg).toContain("evil.com"); + // The other authorized sites must not turn a denial into an enumeration oracle. + expect(msg).not.toContain("github.com"); + expect(msg).not.toContain("internal.corp"); + }); + + it("tells the owner where to configure it when nothing is authorized yet", () => { + const v = judgeUrl("https://github.com", []); + expect(refusalMessage(v as Extract)).toContain("What I expose"); + }); +}); + +describe("origin gate — which browser the agent got decides the default", () => { + it("an empty list means REFUSE for the owner's own browser, and the open web for a launched one", () => { + // attach (unrestricted=false): unset is inert, not open. + expect(judgeUrl("https://anything.example/", []).allowed).toBe(false); + expect((judgeUrl("https://anything.example/", []) as { reason: string }).reason).toBe("no-allowlist"); + // launch (unrestricted=true): a browser with no cookies has nothing to wall off. + expect(judgeUrl("https://anything.example/", [], true).allowed).toBe(true); + }); + + it("keeps the scheme rule even when nothing is walled off", () => { + // "Unrestricted" means the whole WEB, not the whole machine: the local disk and Chrome's + // own settings — including the page that governs remote debugging — are still refused. + for (const url of ["file:///etc/passwd", "chrome://settings", "javascript:alert(1)"]) { + expect(judgeUrl(url, [], true).allowed).toBe(false); + } + }); + + it("a named list still narrows a launched browser", () => { + // Naming domains is a real narrowing in either mode; only the EMPTY case differs. + expect(judgeUrl("https://other.example/", ["github.com"], true).allowed).toBe(false); + expect(judgeUrl("https://gist.github.com/", ["github.com"], true).allowed).toBe(true); + }); +}); diff --git a/tests/p3-5-linux-confinement.test.ts b/tests/p3-5-linux-confinement.test.ts index b02cc83..2cf246d 100644 --- a/tests/p3-5-linux-confinement.test.ts +++ b/tests/p3-5-linux-confinement.test.ts @@ -75,6 +75,9 @@ const ALL_FIRST_PARTY = [ "sysinfo", "shortcuts", "browser", + // CDP-driven page control. macOS-gated like `browser`: Chromium-only, and the launch path + // resolves Chrome at a macOS path — a Linux gateway reserves the id without advertising it. + "browser-control", ] as const; const EXEC_IDS = ["codex", "claudecode"] as const; // The portable (Linux-active) first-party ids that are ALWAYS active on linux (exec-gate diff --git a/tests/p3-platform-gate-modules.test.ts b/tests/p3-platform-gate-modules.test.ts index d0a7c08..c21d80d 100644 --- a/tests/p3-platform-gate-modules.test.ts +++ b/tests/p3-platform-gate-modules.test.ts @@ -34,6 +34,9 @@ const ALL_FIRST_PARTY = [ "sysinfo", "shortcuts", "browser", + // CDP-driven page control. macOS-gated like `browser`: Chromium-only, and the launch path + // resolves Chrome at a macOS path — a Linux gateway reserves the id without advertising it. + "browser-control", ] as const; const LINUX_PORTABLE = ["workspace", "sysinfo"] as const; const GATED_ON_LINUX = [ @@ -47,6 +50,7 @@ const GATED_ON_LINUX = [ "codex", "shortcuts", "browser", + "browser-control", ] as const; /** A fake PlatformServices pinned to the given OS — no real OS access (none used here). */