Skip to main content
Glama

secure-browser

Browser automation for coding agents where the agent can use an authenticated session but cannot read the credential that created it.

Built for Claude Code and the Playwright MCP server, on Linux. The pattern is not specific to either: a login helper and a browser server run as a separate OS user, the agent talks to the browser over MCP, and the credential never enters the agent's process, files, or transcript.

Why

An agent driving a browser needs to log in. The naive approach, a password in a file the agent reads, puts the credential into model context, which is transmitted to an API, summarised, and persisted in transcripts. Moving it to an encrypted store helps, but if the agent can run the store's show command, the store is a convention rather than a boundary.

Here the credential is owned by a separate OS user. The Playwright MCP server runs as that user, so the agent drives an authenticated browser while the calling user, and therefore the agent's shell, file and search tools, cannot read the vault token, the credentials, or the session cookies.

What this buys: the credential cannot be exfiltrated into a transcript, an API, a log, or anything outliving the session. The server's code-execution tool, which would have been the way around that, is removed by a proxy that runs with the server (see "Tool filtering").

What it does not buy by design: it does not stop the agent doing something undesirable while driving an authenticated browser. That risk is accepted and controlled by the user's approval gates on outward-facing actions.

Related MCP server: agentlock-browser

Tool filtering

The stock @playwright/mcp server includes browser_run_code_unsafe, which runs arbitrary Node code inside the server process. Here that process is the secrets user, so the tool could read the service-account token, dump the cookie jar with page.context().cookies(), or op read the vault. --caps only adds capabilities and neither the command line nor the config file can remove a core tool (checked against 0.0.83).

So bin/mcp-server does not run the server directly. It runs mcp-tool-gate, which spawns the server, strips run_code_unsafe from every tools/list reply, and answers any call to it with a JSON-RPC error. The gate and the Playwright server are both npm packages pinned in package.json and package-lock.json; the installer runs npm ci from that lockfile into the root-owned install directory, so the code the secrets user executes is fixed by content hash and writable by nobody but root. The launcher never uses npx: an npx cache lives in the secrets user's home, which that user can write. Refusals are logged to the secrets user's home. browser_evaluate stays: it runs in the page, not the server process.

A client-side deny (a Claude Code permission rule or the PreToolUse hook) would not do: both live in files the calling user, and therefore the agent, can edit.

The launcher also runs with the exchange directory as its working directory rather than the secrets user's home. The server treats cwd as the workspace root and browser_file_upload will attach any file under it, so with $HOME as the root the token file was uploadable to any site.

History. Both paths were found by the author in a self-audit before the first public commit, not through an incident, and the README of that commit recorded them as an open gap while this fix was built. Remaining optional hardening: run the browser server as a third user that holds the cookie file but not the vault token, so that even a future code-execution tool upstream could not reach the vault.

Layout

secure-browser/
  bin/            login helper, MCP server launcher, caller-side CLI
  package.json    pinned runtime (Playwright MCP server + mcp-tool-gate), installed by the installer
  sites.d/        one JSON per site: login URL, expected origin, selectors, secret refs
  hooks/          PreToolUse guard that denies the unscoped Playwright server
  setup/          privileged installer (run by a human)
  claude/         the skill, the CLAUDE.md section and the hook registration
  CHANGELOG.md    dated history

Three enforcement layers

Layer

Where

Strength

Skill

claude/skills/secure-browser/SKILL.md

procedures; fires on description match

Instruction

claude/CLAUDE.md.snippet, pasted into the project's CLAUDE.md

always loaded

Hook

hooks/guard-playwright.py

deterministic: denies any Playwright MCP call under the root that is not on the playwright-secure server. It guards which server is called; what that server can run is bounded by the tool filter (see "Tool filtering")

The hook has three modes in hooks/mode (missing file means warn):

  • warn: logs violations to hooks/guard.log, allows the call. Correct until the secure server is actually running.

  • enforce: denies any Playwright call under the root that is not on the playwright-secure server.

  • off.

Leave it on warn until setup/install.sh has run and a login works end to end. Switching to enforce first makes all browser automation fail.

Credential store

1Password, read with the op CLI as the secrets user, using a service account whose token lives at ~<secrets user>/.config/op/service-token (mode 600, directory 700). The calling user cannot read the token, and the helper never prints it or passes it in argv; it goes into the child environment of op read and nowhere else.

The token is the root secret: unattended by design, protected by the OS user boundary. Two consequences shape the setup:

  • Scope it to one vault. A service-account token is cloud-scoped: whoever holds it can read every vault it was granted, from anywhere. So the service account gets read-only access to a single vault, which holds only the credentials automations are allowed to read. Private and shared vaults are never granted.

  • Login needs the network. op read calls 1password.com. An outage shows up as FAILED <site>: 1Password unreachable.

Site files reference fields as op://<vault>/<item title>/<field>. The user manages the items in the 1Password apps like any other login; the same item serves both the user and the helper, so a password rotation is one edit. Legacy pass entries (no op:// prefix) still resolve through a local pass store if one exists.

Setup, done by the user in the 1Password web app: create the vault, create the Login items (default username/password field names), create a service account under Developer with read access to that vault only, and paste its token into the installer's hidden prompt. 1Password shows the token once; keep a copy in your own Private vault.

Install

Prerequisite: a system-wide Node 20+. The MCP server runs as the secrets user under sudo, which resets PATH to secure_path, so a user-level nvm Node is invisible to it. Debian and Ubuntu's apt nodejs is too old for @playwright/mcp. The installer checks and stops if it is missing.

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs
/usr/bin/node -v                  # must print v20 or higher

The installer also needs python3-venv for the secrets user's Playwright venv and installs it itself.

sudo bash setup/install.sh        # user, group, exchange dir, sudoers rule, /opt copy + npm ci, op CLI, token
newgrp browseragent               # group membership does NOT apply to already-open shells
secure-browser status             # should show everything present

The install step runs npm ci inside the install directory from the committed package-lock.json, so it needs the npm registry once. Upgrading the server or the gate is a package.json bump, npm install in the repo to refresh the lockfile, commit, re-run the installer.

Names and paths are overridable with SECURE_BROWSER_AGENT_USER, SECURE_BROWSER_GROUP, SECURE_BROWSER_INSTALL and SECURE_BROWSER_EXCHANGE, set for the installer and for the caller-side CLI alike. Defaults: pwagent, browseragent, /opt/secure-browser, /srv/secure-browser-exchange.

Then add a site: copy sites.d/EXAMPLE-ats.json.template to sites.d/<site>.json, fill it in, create the matching Login item in the vault, and re-run the installer. Real site files are gitignored on purpose; see sites.d/README.md.

Registering the server

Add to ~/.claude.json (user scope) or the project's .mcp.json:

"mcpServers": {
  "playwright-secure": {
    "command": "sudo",
    "args": ["-n", "-u", "pwagent", "/opt/secure-browser/bin/mcp-server"]
  }
}

The name must stay playwright-secure: the guard hook recognises the hardened server by that string in the tool name. Register the hook with the block in claude/settings.hooks.json, and paste claude/CLAUDE.md.snippet into the project's CLAUDE.md. Copy claude/skills/secure-browser/ into the project's .claude/skills/.

How a login reaches the MCP server

login signs in with Playwright-Python and saves the cookies to ~<secrets user>/.browser-profile/storage-state.json (one file for all sites, mode 600). The MCP server runs --isolated and seeds its context from that file at startup only. So:

  1. secure-browser login <site> → OK ... session saved

  2. start a new agent session; the server is spawned at startup and reads the file then

A login done while a session is open does not reach the already-running server. Cookies the agent picks up during a session are in memory only and are discarded at exit; the helper's file is the source of truth. login is safe to run at any time: when the saved cookies are still valid it reports existing session reused without touching the credential.

Everyday use

secure-browser status                  # what is installed and what is not
secure-browser login <site>            # OK / FAILED / NEEDS-HUMAN, one line
secure-browser login <site> --check-secrets   # prove the store path; nothing is typed
secure-browser put ./upload.pdf        # stage a file for upload, prints the path
secure-browser ls                      # what is staged
secure-browser clean upload.pdf        # remove it again

Why the registry is not editable by your normal user

sites.d/*.json says where a credential gets typed. A site file therefore carries the same authority as the credential itself: anyone who can edit one can point a password at a host they control. So the registry is installed to root-owned /opt alongside the executables, and adding a site means re-running the installer.

That is real friction, and it is the price of the guarantee. The same reasoning applies to bin/login: if your user could edit the script that runs as the secrets user, the boundary would be decorative.

Error taxonomy

login prints exactly one line and nothing else.

Output

Meaning

OK <site>: existing session reused

cookies still valid; no credential was touched

OK <site>: signed in, session saved

credential used, session persisted

NEEDS-HUMAN <site>: ...

captcha, MFA or an interstitial. Stop and tell the user; do not attempt it

FAILED <site>: origin mismatch ...

the page was not where it should be. Nothing was typed. Treat as hostile, do not retry blind

FAILED <site>: no item or field at 'op://...'

vault, item title or field name does not match 1Password, or the service account cannot read that vault

FAILED <site>: 1Password rejected the service-account token

token revoked, mistyped, or not granted this vault; create a new one and re-run the installer

FAILED <site>: 1Password unreachable

network or DNS from this machine; retry later

FAILED <site>: 1Password service-account token missing

installer has not stored a token yet

FAILED <site>: playwright is not installed ...

environment, not security

FAILED <site>: <ExceptionType> in the login helper

unexpected. Detail goes to ~<secrets user>/login-errors.log, mode 600

A traceback is never printed, because it can carry page fragments or the value. The exception type is printed, because an opaque failure is its own kind of risk.

Status

See CHANGELOG.md. The design has been in daily use by the author since 2026-10-11.

License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    Policy-gated MCP server for local-first browser automation, enabling agents to drive isolated Chromium sessions via snapshots, revision-bound refs, typed actuation, scripts, and telemetry receipts.
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents to drive a real, already logged-in browser from the CLI or over MCP, with enforceable approval gates for actions and safeguards against prompt injection.
    1,969,715 npm
    Mozilla Public 2.0