# BrowserSkill: the agent has to borrow your tab to click it, not to read it

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/browserskill
> date: 2026-09-18
> tags: agents, browser-automation, security, tooling, explainer
[BrowserSkill](https://github.com/Tencent/BrowserSkill) is a Rust CLI, a local daemon, and an MV3 Chromium extension that together let any agent which can call a shell drive the browser you are already signed into. The agent runs `bsk click @e3 --session ab12`; the CLI talks to the daemon over a Unix socket; the daemon forwards a JSON-RPC frame to the extension over a loopback WebSocket; the extension drives Chrome DevTools Protocol in a separate **Agent Window** so your own windows keep working. It is MIT-licensed, 504 commits old as I write this, and the README's promise is one sentence:

> Need the agent to touch a tab you already have open? It must borrow that tab explicitly, return it when the task is done, and leave the rest of your browser alone.

I spent a day checking that sentence. The interesting part is that it is exactly half true, in a way the code states on purpose and the documentation never does.

| | |
|---|---|
| Project | [Tencent/BrowserSkill](https://github.com/Tencent/BrowserSkill) · MIT · Rust + TypeScript workspace |
| Version checked | CLI / extension / DSH plugin all `0.3.0`, daemon protocol `1.3`, commit `d1356fd` (2026-09-18) |
| Surface | 41 `tool.*` protocol methods · 40 dispatched in the extension · 37 top-level CLI commands |
| Extension permissions | `debugger`, `tabs`, `scripting`, `downloads`, `notifications`, `webNavigation`, `windows`, `storage`, `alarms`, `idle`, `activeTab` + host permission for all URLs |
| Runtime I used | `cargo build -p bsk --locked`, the repo's own `pnpm ext:build`, Chromium 153 via `--load-extension` |
| Defaults | WebSocket on `127.0.0.1:52800` · session idle timeout 5 min · daemon idle 30 min · borrow confirmation wait 60 s |

<Callout type="note">
Everything below was run locally against the code in the repository, not against a published binary. Where I could not run something — remote pairing, in particular — I say so and name what I used instead.
</Callout>

## The only structural question

An agent that can call a shell already owns the shell. It can read your files, start processes, and edit anything your user account can edit. So in any tool of this shape, exactly one question decides whether a permission is a permission: **is the switch on a surface the agent controls?** A flag is not consent. An environment variable is not consent. A config file in the agent's own home directory is not consent. They are defaults, and an agent that wants them changed can change them.

BrowserSkill 0.3.0's headline change is an answer to that question, and it is a change that makes the product *worse* to use — which is the kind of change worth paying attention to. From `CHANGELOG.md`:

> **Automation settings:** the extension's saved borrow-confirmation and human-help switches govern existing and new sessions. `--unattended`, `tab borrow --no-confirm` and `BSK_REQUEST_HELP=off` are deprecated compatibility inputs and cannot override these switches.

Three ways to run unattended, all removed, all kept parseable so nobody's script hard-fails. The entire remaining implementation of those three inputs is this, from `crates/bsk-cli/src/cli/interaction_policy.rs`:

```rust
/// Keep legacy inputs parseable without letting them change browser policy.
pub(crate) fn warn_legacy_override(option: &str) {
    tracing::warn!(
        "{option} is deprecated and has no effect; Automation settings in the browser extension control confirmation and human help"
    );
}
```

Four call sites across three inputs — `BSK_REQUEST_HELP=off` is checked twice, once in the CLI and once in the daemon's inherited environment — and nothing downstream reads them. I checked it against the built binary rather than taking the comment's word for it. With a real daemon up and a real browser attached:

```console
$ bsk tab borrow 1117016028 --session oevn --no-confirm --timeout 8s
WARN bsk::cli::interaction_policy: --no-confirm is deprecated and has no effect;
     Automation settings in the browser extension control confirmation and human help
error: timed out waiting for human confirmation
hint: report the blocked step; do not automatically repeat the request or switch browser tools
details: Timed out waiting for tab borrow confirmation
```

Eight seconds of real waiting, then a refusal. `--no-confirm` did not skip the prompt; it printed a warning and the borrow sat there asking a human who was never going to answer. The same happened for `--unattended` on `session start` and for `BSK_REQUEST_HELP=off` on `request-help`. The agent can also *read* the policy it is subject to but not change it — `bsk session list --json` on that session returned `"interaction": {"borrow_confirmation": "always", "request_help": "enabled"}`.

The switch itself lives in `chrome.storage.local`, in your browser profile, and the normalizer is fail-closed. From `apps/extension/src/lib/interaction-preferences.ts`:

```ts
export const DEFAULT_INTERACTION_PREFERENCES: InteractionPreferences = {
  confirmTabBorrow: true,
  requestHelpEnabled: true,
}

export function normalizeInteractionPreferences(value: unknown): InteractionPreferences {
  const prefs = value as Partial<InteractionPreferences> | null | undefined
  return {
    confirmTabBorrow: prefs?.confirmTabBorrow !== false,
    requestHelpEnabled: prefs?.requestHelpEnabled !== false,
  }
}
```

`!== false` rather than a truthiness check, so corrupted, missing, half-written or wrongly-typed storage all resolve to *on*. The only value that disables confirmation is the literal boolean `false`, which only the popup writes. And the confirmation is rendered somewhere the agent cannot reach, for a reason the code spells out in `apps/extension/src/entrypoints/background.ts`:

```ts
// Skip every Agent Window when choosing where to render the
// overlay — Agent Windows boot on about:blank, which has no
// content script, so they cannot surface an authorization decision.
isAgentWindowId: (windowId) => sessions.findByWindowId(windowId) !== null,
```

The approval prompt always appears in a window you own, plus an OS notification in case the Agent Window stole focus. That is careful work, and it is the part of this release I would copy.

<AuthorityMap />

## Where the boundary actually is

Here is BrowserSkill's own picture of the system, rendered from the Mermaid source in its README. Note the dotted edge on the right and what it says.

<Figure
  src="/articles/browserskill/fig2.png"
  alt="Flowchart in three stacked boxes. Top box 'Agent Harness' contains 'Cursor / Claude Code / Codex / OpenClaw', with an arrow labelled 'shell: bsk ...' down to a middle box 'Your Machine' containing 'bsk CLI', then 'local IPC' to 'bsk daemon', then 'WebSocket on 127.0.0.1' to 'BrowserSkill extension'. From the extension, a solid arrow labelled 'automates' points into a bottom box 'Browser Profile' at a highlighted orange node 'Agent Window', and a dotted arrow labelled 'borrow tab only when asked' points at a grey node 'Your normal browser windows'."
  caption="Solid edge to the Agent Window, dotted edge to your windows, labelled 'borrow tab only when asked' (BrowserSkill README, 'How It Works' — rendered from the repository's own Mermaid source at commit d1356fd)."
/>

The enforcement for that dotted edge is one function in `apps/extension/src/tools/shared.ts`, and its doc comment is the whole finding:

```ts
/**
 * Unified target-scope policy by tool effect. Passive reads may inspect user
 * tabs; any tool that dispatches page input must stay inside the Agent Window.
 */
export function enforceToolTargetScope(
  ctx: SessionContext,
  target: { tabId: number; windowId: number },
  effect: ToolEffect,
  toolName: string,
): RpcError | null {
  if (effect === "passive_read" && !ctx.remote) return null
  return enforceAgentWindow(ctx, target, toolName)
}
```

`ToolEffect` is `"passive_read" | "transient_input" | "browser_mutation"`. In a local session, a passive read returns early and is never scope-checked at all. And the resolver that hands these tools a target says the same thing one layer down, again in a comment rather than in any document a user reads:

> Explicit `tabId` values are checked against the session visibility rules: user tabs and the current session's Agent Window are visible, other sessions' Agent Windows are not.

So I ran it. `cargo build -p bsk --locked`, the repo's own `pnpm ext:build`, the resulting `dist/chrome-mv3` loaded into Chromium 153 with `--load-extension`, a real daemon on protocol 1.3. One fixture page served on loopback, titled *Private inbox*, containing the string `CANARY-9f3c1d-private-inbox` and a button labelled *Transfer funds*, opened in an ordinary browser window. Then a session, and nothing else — no borrow, no confirmation, no notification.

```console
$ bsk tab list --scope user --session oevn --json
{"tabs":[{"tab_id":1117016028,"title":"Private inbox","url":"http://127.0.0.1:8099/",
          "window_id":1117016026,"active":true,"scope":"user"}]}

$ bsk get-html --tab-id 1117016028 --session oevn
<!DOCTYPE html><html><head><title>Private inbox</title>…</head>
  <body><h1>Private inbox</h1><p id="s">CANARY-9f3c1d-private-inbox</p>
  <button id="danger">Transfer funds</button></body>…

$ bsk snapshot --tab-id 1117016028 --session oevn
@vom 1
@view 800x600
@layers 1 focus=L1
L1 page
  RootWebArea "Private inbox"
    heading "Private inbox"
      StaticText "Private inbox"
    paragraph "CANARY-9f3c1d-private-inbox"
      StaticText "CANARY-9f3c1d-private-inbox"
    @e1 button "Transfer funds"

$ bsk click @e1 --tab-id 1117016028 --session oevn
error: operation denied by the Agent Window sandbox
hint: tabs outside an Agent Window must first be borrowed via `bsk tab borrow <tab-id> --session <id>`
details: click can only act on tabs inside the Agent Window (tab 1117016028 is in window 1117016026; borrow it first)
```

That is the shape of it. `snapshot` even minted `@e1` for the *Transfer funds* button — a live, resolvable ref pointing at a control the session is not allowed to press. Reading the page is free; touching it is not.

<ScopeMatrix />

I ran nineteen invocations in that session: the six that read, and thirteen more spanning input, navigation, emulation, file transfer and the help request. The split is clean. The commands I skipped — `focus`, `blur`, `wheel`, `select`, `upload` and the history pair — route through the same `enforceAgentWindow` call as the ones I ran, so I read those call sites instead of exercising them.

**Receipts.** With a BrowserSkill 0.3.0 session running locally, six of its commands read a tab in the user's own window when handed that tab's id, with no borrow and no confirmation prompt. Thirteen refuse with permission_denied / agent_window_scope and exit 1. This is the full set I ran; nothing is omitted.

| bsk command | on an unborrowed user tab | exit | what came back |
| :--- | :--- | ---: | :--- |
| tab list --scope user | allowed | 0 | tab_id, title and url of every tab in the user's windows |
| get-html | allowed | 0 | full outerHTML, including the canary string |
| snapshot | allowed | 0 | accessibility tree plus an actionable @e1 ref |
| screenshot | allowed | 0 | 9,923-byte PNG of the page (fig3 below) |
| console | allowed | 0 | buffered console entries for that tab |
| network | allowed | 0 | buffered network entries for that tab |
| observe | denied — agent_window_scope | 1 | "observe can only act on tabs inside the Agent Window" |
| click | denied — agent_window_scope | 1 | "borrow it first" |
| fill | denied — agent_window_scope | 1 | "borrow it first" |
| press | denied — agent_window_scope | 1 | "borrow it first" |
| hover | denied — agent_window_scope | 1 | "borrow it first" |
| scroll-to | denied — agent_window_scope | 1 | "borrow it first" |
| navigate | denied — agent_window_scope | 1 | "borrow it first" |
| reload | denied — agent_window_scope | 1 | "borrow it first" |
| evaluate | denied — agent_window_scope | 1 | "borrow it first" |
| screenshot --full-page | denied — agent_window_scope | 1 | also requires a per-tab claim, not just the window |
| emulate | denied — agent_window_scope | 1 | "borrow it first" |
| download | denied — agent_window_scope | 1 | "borrow it first" |
| request-help | denied — agent_window_scope | 1 | "borrow it first" |

The Agent Window for this session was window 1117016029; the fixture tab lived in window 1117016026, the one the human was using. `observe` is denied and `snapshot` is allowed because BrowserSkill classifies observe as transient_input (it can hover-probe the live page) and snapshot as passive_read.

> method: cargo build -p bsk --locked at commit d1356fd, real daemon (protocol 1.3) + the repo's own extension build loaded into Chromium 153 via --load-extension. A fixture page at http://127.0.0.1:8099/ containing the string CANARY-9f3c1d-private-inbox was opened in an ordinary browser window, never borrowed. Each command was invoked as `bsk <cmd> --tab-id 1117016028 --session oevn`; 'denied' means the output carried the agent_window_scope reason.
> source: https://github.com/Tencent/BrowserSkill
> captured: 2026-09-18
> data: https://ai.thesatyajit.com/articles/browserskill/data/scope-matrix.json (19 rows)

The screenshot in that table is worth showing, because it is the least abstract way to say what happened. This image was produced by `bsk screenshot --tab-id <the user's tab>` during the run above. Nothing was borrowed, nothing was confirmed, and the page it captures is in the window the human was using.

<Figure
  src="/articles/browserskill/fig3.png"
  alt="A plain white browser page captured at 780 by 437 pixels. It shows the heading 'Private inbox', the paragraph text 'CANARY-9f3c1d-private-inbox', and a small button labelled 'Transfer funds'. There is no overlay, badge or banner anywhere on the page."
  caption="The 9,923-byte PNG that bsk screenshot wrote for a tab in my own window, with no borrow and no prompt. Captured during this article's own run, 2026-09-18 — this one is my artifact, not the project's."
/>

There is no visible indication on the page either. The extension injects a `browser-skill-overlay` element into every page it can script, but the background service worker only populates it for tabs the session has actually claimed — `if (ctx && isAgentControlledTab(ctx, tabId)) return overlayStateForWindow(windowId)`. A passive read of an unclaimed tab leaves the element empty, so the "Agent Active" control overlay never appears. I read that from the source; the screenshot above is consistent with it but does not prove it on its own, because the capture path could in principle have hidden an overlay.

## Why I think this is deliberate, and why it still matters

Three pieces of evidence say this is a decision, not an oversight.

The first is the comment quoted above, which describes the behaviour in a single sentence and does not hedge. The second is that the *strictest* check in the entire codebase is applied to a read: `screenshot --full-page` requires the Agent Window scope **and** an unconditional per-tab claim, and re-verifies both before every scroll step, because a full-page capture scrolls the page and therefore mutates it. Somebody thought about which reads are really reads. The third is remote mode, where the same guard flips:

```ts
export function enforceAgentWindow(ctx, target, toolName): RpcError | null {
  if (ctx.remote && !isAgentControlledTab(ctx, target.tabId)) {
    return { code: "permission_denied",
             message: "This tab has not been authorized for the remote task" }
  }
  if (target.windowId !== ctx.agentWindowId) { /* …borrow it first… */ }
  return null
}
```

`isAgentControlledTab` is the per-tab grant — `agentCreatedTabs` union `borrowedTabs` — and it is consulted only when the session is remote. The unit test that pins this down is even titled `remote tab ownership`, and it constructs its manager with `remote: () => true`. There is no local equivalent, because locally there is nothing to assert.

Read it as a threat model and it is coherent. A remote agent sits outside your machine, so every page it reads needs an explicit grant. A local agent is already inside: it has your shell, and with your shell it has your Chrome profile on disk, your cookie database, and the ability to launch its own instrumented browser. Making `bsk snapshot` refuse a user tab would not deny a determined local agent anything; it would only make the honest path more annoying than the dishonest one. I think that reasoning is right.

What I do not think is right is that the reasoning appears nowhere a user would find it. The README says the agent must borrow a tab to touch it. `skill/SKILL.md` — the file installed into Cursor, Claude Code and Codex, and therefore the sentence the *model* reads before deciding what it may do — opens with "User tabs require explicit borrowing." And the Chrome Web Store privacy disclosure, `apps/extension/PRIVACY.md`, scopes page-content access to exactly three cases:

> **Web page content** — The DOM, accessibility tree, HTML, and screenshots of pages controlled in the "Agent Window," tabs borrowed according to the browser's confirmation setting, or pages selected for user-initiated Quick Actions.

My canary string came back from a fourth case: a tab in a user window, named by id, in a local session, never borrowed, never a Quick Action. The tab-metadata row two lines below is scrupulous — it discloses that user tab IDs, URLs and titles are read "including user tabs listed to select a tab for borrowing" — which makes the page-content row's omission look like a line that was written before `passive_read` existed and never revisited, rather than an attempt to hide anything. It is a documentation bug with a privacy-disclosure blast radius, and it is one sentence to fix.

There is a second-order consequence worth naming for anyone integrating this. A prompt-injected agent that has been talked into exfiltrating something does not need to win a confirmation dialog. It needs `tab list --scope user`, which gives it the titles and URLs of everything you have open, and then `get-html` on the interesting one. The mutation boundary, which is the one BrowserSkill built carefully and which genuinely holds, is not on that path at all.

## The eval corpus, and the denominator under the denominator

BrowserSkill ships something most tools of this kind do not: `evals/browser/`, a deterministic, agent-neutral capability corpus with local fixture pages, a declarative smoke workflow, seeded DOM variation, and an oracle that reports site events, response text and adapter evidence separately. Its design goals include a line I wish more projects wrote down:

> Honest verification: page-observable results, response markers, and adapter evidence are reported separately. Missing adapter evidence is `unverified`, never silently treated as passed.

It also carries a number: *"The direct smoke lane covers 25 of 28 operations."* That reproduces exactly. `node evals/browser/cli.mjs validate` reports 9 cases and 18 fixture routes across core 6 / matrix 1 / regression 2; `coverage` marks 25 operations `direct_smoke: yes` and three `manual` — `tabs.borrow`, `tabs.return` and `assist.request-help`, each with a one-line reason for why it cannot be automated; and the harness's own 15 unit tests pass.

So the claim is true. The question the house method asks next is what 28 is a count of.

<CoverageLadder />

Two things fall out of that. The smaller one: the corpus's `coverage` arrays — the operations a case declares for an *agent* run, as opposed to the scripted CLI workflow — name 21 of the 28, not 25. `session.list`, `inspect.snapshot`, `tabs.list` and `assist.resize` are exercised only by the deterministic smoke steps, never by a prompt handed to a model. That is a reasonable design and the `coverage` command prints both columns side by side; it just means the headline number describes the CLI lane, and the agent lane is four operations thinner.

The larger one: 28 is the corpus's own inventory, and it maps onto 28 of the 41 `tool.*` methods the protocol declares. Thirteen methods have no case at all, and seven of those thirteen are exactly the seven `tool.*` methods 0.3.0 introduced: `wheel`, `scroll_to`, `focus`, `blur`, `screenshot_full_page`, `screenshot_read` and `screenshot_release`. I checked that against the protocol at tag `cli-v0.2.1`, where none of the seven exists. Every browser method the current release added is untested by the corpus that ships beside it. Of the remaining six, `upload` and `download` arrived in 0.2.0 and the recorder's three methods earlier still; only `evaluate` was in the protocol at the first public commit. New capability outrunning its test corpus is the normal condition of every codebase I have worked in. It is worth stating plainly only because the published fraction, 25/28, reads as 89%, and the fraction against the surface it is drawn from is 25/41.

**Receipts.** BrowserSkill's eval README says "the direct smoke lane covers 25 of 28 operations." Running its own harness reproduces that exactly. The number the README does not give is the denominator behind the denominator: those 28 operations map onto 28 of the 41 tool.* methods the protocol declares, so 13 browser methods have no case in the corpus at all — including all seven that 0.3.0 added, none of which is present in the protocol at tag cli-v0.2.1.

| measure | count | how it was produced |
| :--- | ---: | :--- |
| cases in the corpus | 9 | cli.mjs validate — core 6, matrix 1, regression 2 |
| fixture routes | 18 | cli.mjs validate |
| operations in the inventory | 28 | OPERATION_CATALOG in evals/browser/lib/operations.mjs |
| operations the direct smoke lane reaches | 25 | cli.mjs coverage — direct_smoke = yes |
| operations left to the manual lane | 3 | tabs.borrow, tabs.return, assist.request-help |
| operations any agent prompt declares | 21 | cli.mjs coverage — rows with a non-empty agent_cases column |
| tool.* methods the protocol declares | 41 | crates/bsk-protocol/src/method.rs |
| tool.* methods the extension dispatches | 40 | apps/extension/src/tools/dispatcher.ts |
| tool.* methods with no eval operation | 13 | the 41 protocol methods minus the 28 the catalog names |
| harness unit tests, all passing | 15 | node --test evals/browser/tests/*.test.mjs |

The 13 unevaluated methods are wheel, scroll_to, focus, blur, upload, download, screenshot_full_page, screenshot_read, screenshot_release, evaluate, record_start, record_stop and record_await. tool.wait_ms is the one protocol method the extension never sees — the daemon answers it (crates/bsk-cli/src/daemon/ipc.rs:301), so the sleep never reaches Chrome.

> method: node evals/browser/cli.mjs validate | coverage | list, and node --test evals/browser/tests/*.test.mjs, run at commit d1356fd with no local changes. The protocol count is the number of #[serde(rename = "tool.…")] variants in crates/bsk-protocol/src/method.rs; the dispatcher count is the number of distinct case "tool.…" labels in apps/extension/src/tools/dispatcher.ts.
> source: https://github.com/Tencent/BrowserSkill/blob/main/evals/browser/README.md
> captured: 2026-09-18
> data: https://ai.thesatyajit.com/articles/browserskill/data/eval-coverage.json (10 rows)

One incidental find while counting: `tool.wait_ms` is the single protocol method the extension never sees. The daemon answers it directly in `crates/bsk-cli/src/daemon/ipc.rs`, so `bsk wait-ms` is a sleep that occupies the session queue and never reaches Chrome. That is the right implementation; it is just not what "browser tool" suggests.

## Small things that do not line up

**The architecture doc is off by nineteen.** `docs/architecture.md` describes the extension's `tools/` directory as `ToolDispatcher → 21 tool handlers`. The dispatcher's switch has 40 distinct `case "tool.…"` labels. `git log -L` on that line shows it has not been touched since commit `f1036a8`, *"Initial public release"*, on 2026-06-22 — it was accurate then and the code roughly doubled underneath it. Harmless, but it is the first file a contributor reads.

**One document is Chinese-only, and it is the one about accountability.** Every file under `docs/` is English except `docs/operation-audit.md`, which is 59.6% CJK characters against 0.0% for all six of its neighbours. The English README and the English changelog both link straight to it. I read it in the original, and it is good: audit is **off by default**, records live under `~/.bsk/audit` on the daemon host at mode `0700`/`0600`, one `.jsonl` per task capped around 16 MiB, thirty-day retention for finished tasks, no screenshots or page bodies or input values or file paths written, and page origins reduced to scheme, host and port. It also contains the most self-critical sentence in the repository, which nobody reading only English will ever see:

> 本功能用于个人回顾，不提供防篡改或不可抵赖保证。… daemon 主机上的文件不构成对同一系统账户下恶意进程的安全边界。
>
> ("This feature is for personal review; it offers no tamper-resistance or non-repudiation guarantee. … Files on the daemon host do not constitute a security boundary against a malicious process under the same system account.")

That is a project publishing against its own interest, in the only place it published at all. The audit trail sits inside the agent's reach and says so. It is also, by default, not running.

**The version gate on `request-help` is a compatibility check wearing a security check's clothes.** The CLI refuses `request-help` against a daemon older than protocol 1.3, and the comment explains why: *"An old daemon can return locally without letting the browser decide."* The mechanism is `require_daemon_support`, which asks the daemon for its protocol version and believes the answer. That is exactly right for the failure it names — a half-upgraded install — and it is worth being clear that it does not survive an adversary, because the component being asked is the one that would lie, and an agent with a shell can install whichever `bsk` it likes.

**The committed listing screenshot still shows 0.1.x.** `docs/assets/browserskill-chrome-web-store-screenshot-1280x800.png` is the store-listing image kept in the repo, and the popup inside it reads `0.1.1 / 0.1.4` — CLI and extension — against a shipping 0.3.0. I checked the committed asset, not the live store page. Cosmetic either way. I include it because its three feature tiles are the product's own summary of the promise, and the third one is the claim this whole article is about.

<Figure
  src="/articles/browserskill/fig1.png"
  alt="A dark Chrome window used as a store listing image. The BrowserSkill logo and wordmark sit above the tagline 'Real browser access for any local AI agent'. Three outlined tiles below read 'Real Browser', 'Any Agent', and 'You keep browsing'. On the right, a mock extension popup shows a green connected dot with Chinese text, a READY badge, a browser-alias field, and the version string 0.1.1 / 0.1.4."
  caption="The listing image committed in the repo: 'Real Browser', 'Any Agent', 'You keep browsing' — and a popup still showing 0.1.1 / 0.1.4 against a shipping 0.3.0 (BrowserSkill, docs/assets, Figure 1)."
/>

## The ledger

**Real, and checked.** The consent relocation works exactly as advertised. Three deprecated bypasses, all measured against the built 0.3.0 binary with a live browser attached, all inert: `--no-confirm` warned and then let the borrow block for its full timeout and fail. The preference normalizer is fail-closed on anything that is not the literal boolean `false`. The approval prompt renders in a window the agent cannot script, for a stated reason. The Agent Window sandbox holds: thirteen commands, including `evaluate`, refused a tab in my own window with `permission_denied` and exit 1. And `evals/browser/` is a real, runnable, honestly-scoped corpus whose headline number reproduces to the operation.

**Narrower than advertised.** "It must borrow that tab explicitly" is true of everything that dispatches input and false of `snapshot`, `get-html`, `screenshot`, `console` and `network` in a local session. `PRIVACY.md`'s page-content row lists three sources of page content and there are four. `skill/SKILL.md` tells the model "User tabs require explicit borrowing", which is the instruction, not the enforcement. The behaviour is deliberate, defensible on its own threat model, and documented only in source comments. "25 of 28 operations" is 25 of 41 `tool.*` methods, and every method 0.3.0 added is among the thirteen with no coverage at all.

**Stale, not wrong.** 21 tool handlers in a doc that describes 40. A committed listing screenshot whose popup still reads 0.1.1 / 0.1.4. The one document that admits the audit log is not a security boundary exists only in Chinese, linked from English pages.

None of that makes this a bad tool — it is the most carefully-reasoned consent model I have read in this category, and the one number I most wanted to be true, that the bypass flags really are dead, is true. What did not survive checking is a sentence, repeated in four places, that describes the write boundary as though it were the whole boundary. The fix is documentation, not code, and it is smaller than the paragraph you just read.

<ChangeMyMind>

<Falsifier claim="In a local session, snapshot, get-html, screenshot, console and network read a tab in the user's own window with no borrow and no prompt.">
A run of those five commands against an unborrowed user tab that returns `permission_denied`. I ran each of them once, on 2026-09-18, on Linux, against Chromium 153 with an extension I built from `d1356fd` rather than installed from the Web Store — a store build that differs from the repo, or a platform difference in how `chrome.tabs.get` resolves cross-window ids, would settle it. The [scope matrix dataset](/articles/browserskill/data/scope-matrix.json) has the exact commands and the session and tab ids.
</Falsifier>

<Falsifier claim="&quot;--unattended&quot;, &quot;--no-confirm&quot; and &quot;BSK_REQUEST_HELP=off&quot; cannot bypass borrow confirmation in 0.3.0.">
Any code path where one of those three inputs changes what the extension does, rather than only what the CLI logs. I grepped all four call sites of `warn_legacy_override` and then watched a borrow with `--no-confirm` block for its whole eight-second timeout and fail. A daemon-side path I did not find would overturn this, and so would a version older than 0.3.0 — the flags did work before.
</Falsifier>

<Falsifier claim="The read/write split is deliberate rather than an oversight.">
This is the claim I am least able to prove, since it is about intent. The evidence is a doc comment that states the policy in one sentence, a stricter check on `screenshot --full-page` than on any mutation, and a remote path that enforces the stronger rule with its own named unit test. A maintainer saying &quot;that comment is aspirational, the local case is a bug&quot; would settle it the other way, and I would rather be wrong about intent than about behaviour.
</Falsifier>

<Falsifier claim="PRIVACY.md's page-content row does not cover the local passive-read case.">
A reading of that row under which &quot;pages controlled in the Agent Window&quot; already includes any tab a session can name. I do not think the words bear it — `enforceAgentWindow` uses &quot;controlled&quot; to mean the opposite — but it is a disclosure document, and disclosure documents get read generously. A revised row, or a Web Store listing whose text differs from the repository's copy, would also change the answer.
</Falsifier>

<Falsifier claim="The eval corpus covers 25 of 41 protocol tool.* methods, not 25 of the browser surface.">
A case manifest, anywhere in `cases/**`, whose `coverage` array names an operation mapping to one of the thirteen I listed. I derived the mapping by hand from `OPERATION_CATALOG` to `method.rs` and it is the one step here a second pair of eyes should repeat; the [coverage dataset](/articles/browserskill/data/eval-coverage.json) lists every count and the command that produced it.
</Falsifier>

<Falsifier claim="Operation audit is off by default and is not a tamper-evident record.">
A default-on audit switch in a shipped build, or a signing or append-only mechanism I missed. The project states both halves itself, in `docs/operation-audit.md`, in Chinese. I translated the two sentences above and would want to know if I read them wrongly.
</Falsifier>

</ChangeMyMind>
