~/satyajit

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

mdjsonmcp

2026-09-18 · 21 min · agents · browser-automation · security · tooling · explainer

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.

ProjectTencent/BrowserSkill · MIT · Rust + TypeScript workspace
Version checkedCLI / extension / DSH plugin all 0.3.0, daemon protocol 1.3, commit d1356fd (2026-09-18)
Surface41 tool.* protocol methods · 40 dispatched in the extension · 37 top-level CLI commands
Extension permissionsdebugger, tabs, scripting, downloads, notifications, webNavigation, windows, storage, alarms, idle, activeTab + host permission for all URLs
Runtime I usedcargo build -p bsk --locked, the repo's own pnpm ext:build, Chromium 153 via --load-extension
DefaultsWebSocket on 127.0.0.1:52800 · session idle timeout 5 min · daemon idle 30 min · borrow confirmation wait 60 s

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:

/// 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:

$ 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:

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:

// 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.

where each switch physically livesBrowserSkill 0.3.0 · d1356fd
the agent's side of the line
same OS user, same filesystem, same process tree
  • the shell itself
    the agent runs bsk, edits ~/.bsk, can start an older daemon, can read the profile on disk
  • --unattendedmoved in 0.3.0
    parsed, logged as deprecated, then discarded — warn_legacy_override() is its whole implementation
  • tab borrow --no-confirmmoved in 0.3.0
    same: the borrow still blocks for a human and times out without one
  • BSK_REQUEST_HELP=offmoved in 0.3.0
    same, in both the CLI and the daemon's inherited environment
  • operation audit
    off by default; written under ~/.bsk/audit on the daemon host, i.e. inside the agent's own reach
your side of the line
browser profile storage and UI the CLI cannot write
  • Confirm before borrowing tabsmoved in 0.3.0
    chrome.storage.local in your browser profile; the extension re-reads it on every borrow
  • Allow requests for human helpmoved in 0.3.0
    same store; re-checked four times through request_help's setup, so flipping it mid-flight takes effect
  • the borrow confirmation itself
    rendered in a window you own — Agent Windows boot on about:blank and have no content script to ask in
  • the Agent Window boundary
    enforceAgentWindow(): every input-dispatching tool refuses a tab outside the session's own window
The three inputs marked moved in 0.3.0 used to be how you turned confirmation off. They still parse. They now do nothing except print a warning on stderr, because the switch they used to flip is on the other side of the line.

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.

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'.
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:

/**
 * 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.

$ 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.

19 commands · one target tab · three places that tab can be6 allowed / 13 denied
measured — Chromium 153, bsk 0.3.0, protocol 1.3, 2026-09-18
allowed (6)
denied (13)
bsk get-htmlpassive_readallowed

Returned the fixture's full outerHTML, canary string included. No borrow, no prompt, no notification.

The middle column is the promise on the tin, and it is kept: nothing that dispatches input reaches a tab you are using. The left column is the same session, the same tab, and the read half of the same tool set — and there the boundary is the window, not a grant. Switch to the right-hand column and the per-tab grant appears, because a remote agent is outside the trust boundary and a local one, by BrowserSkill’s reckoning, already isn’t.

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.

receiptscaptured 2026-09-18

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 commandon an unborrowed user tabexitwhat came back
tab list --scope userallowed0tab_id, title and url of every tab in the user's windows
get-htmlallowed0full outerHTML, including the canary string
snapshotallowed0accessibility tree plus an actionable @e1 ref
screenshotallowed09,923-byte PNG of the page (fig3 below)
consoleallowed0buffered console entries for that tab
networkallowed0buffered network entries for that tab
observedenied — agent_window_scope1"observe can only act on tabs inside the Agent Window"
clickdenied — agent_window_scope1"borrow it first"
filldenied — agent_window_scope1"borrow it first"
pressdenied — agent_window_scope1"borrow it first"
hoverdenied — agent_window_scope1"borrow it first"
scroll-todenied — agent_window_scope1"borrow it first"
navigatedenied — agent_window_scope1"borrow it first"
reloaddenied — agent_window_scope1"borrow it first"
evaluatedenied — agent_window_scope1"borrow it first"
screenshot --full-pagedenied — agent_window_scope1also requires a per-tab claim, not just the window
emulatedenied — agent_window_scope1"borrow it first"
downloaddenied — agent_window_scope1"borrow it first"
request-helpdenied — agent_window_scope1"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.
data /articles/browserskill/data/scope-matrix.json (19 rows, 3.8 KB)

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.

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.
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:

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 manualtabs.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.

“25 of 28” — out of what, exactlybars scaled to 41 protocol methods
direct smoke lane
25/41
the number the README prints — 25 of the 28 operations the corpus names
agent-prompt lane
21/41
operations any case actually asks an agent to perform; the README does not give this one
named in the inventory
28/41
OPERATION_CATALOG — the denominator the 25 is out of
dispatched by the extension
40/41
distinct tool.* handlers in the ToolDispatcher switch
declared by the protocol
41/41
tool.* variants in bsk-protocol; tool.wait_ms is answered by the daemon, never by the browser
13 methods with no case in the corpus
tool.wheeltool.scroll_totool.focustool.blurtool.uploadtool.downloadtool.screenshot_full_pagetool.screenshot_readtool.screenshot_releasetool.evaluatetool.record_starttool.record_stoptool.record_await

Seven of those thirteen are exactly the seven methods 0.3.0 introduced — none of them exists in the protocol at tag cli-v0.2.1, so every browser method the current release added is untested. A corpus that grows more slowly than the tool surface is the ordinary condition of every test suite I have ever shipped, and BrowserSkill’s is unusually honest about its own manual lane. It is the published fraction that flatters: 25/28 reads as 89%, and the browser surface it is drawn from is 41.

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.

receiptscaptured 2026-09-18

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.

measurecounthow it was produced
cases in the corpus9cli.mjs validate — core 6, matrix 1, regression 2
fixture routes18cli.mjs validate
operations in the inventory28OPERATION_CATALOG in evals/browser/lib/operations.mjs
operations the direct smoke lane reaches25cli.mjs coverage — direct_smoke = yes
operations left to the manual lane3tabs.borrow, tabs.return, assist.request-help
operations any agent prompt declares21cli.mjs coverage — rows with a non-empty agent_cases column
tool.* methods the protocol declares41crates/bsk-protocol/src/method.rs
tool.* methods the extension dispatches40apps/extension/src/tools/dispatcher.ts
tool.* methods with no eval operation13the 41 protocol methods minus the 28 the catalog names
harness unit tests, all passing15node --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.
data /articles/browserskill/data/eval-coverage.json (10 rows, 2.7 KB)

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.

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.
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.

What would change my mind

6 claims above, and what would falsify each

  1. 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 has the exact commands and the session and tab ids.

  2. "--unattended", "--no-confirm" and "BSK_REQUEST_HELP=off" 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.

  3. 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 "that comment is aspirational, the local case is a bug" would settle it the other way, and I would rather be wrong about intent than about behaviour.

  4. PRIVACY.md's page-content row does not cover the local passive-read case.

    A reading of that row under which "pages controlled in the Agent Window" already includes any tab a session can name. I do not think the words bear it — enforceAgentWindow uses "controlled" 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.

  5. 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 lists every count and the command that produced it.

  6. 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.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "BrowserSkill: the agent has to borrow your tab to click it, not to read it", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026browserskill,
  author = {Satyajit Ghana},
  title  = {BrowserSkill: the agent has to borrow your tab to click it, not to read it},
  url    = {https://ai.thesatyajit.com/articles/browserskill},
  year   = {2026}
}
share