Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ jobs:
echo "image=secure-browser-mcp:pr-${SHA}" >> "$GITHUB_OUTPUT"
fi

# Kargo's NewestBuild strategy orders images by the
# org.opencontainers.image.created label when it is set. The base
# image sets it, so without our own value every image on the same
# base has the same time, and Kargo cannot see that a build is newer.
- name: Use the commit time as the image created time
id: created
run: echo "time=$(git log -1 --format=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
env:
TZ: UTC

- name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
Expand All @@ -59,3 +69,4 @@ jobs:
provenance: false
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.tag.outputs.image }}
labels: org.opencontainers.image.created=${{ steps.created.outputs.time }}
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/
node_modules
dist/
*.log
.env
Expand Down
9 changes: 7 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# Chrome. Build with --build-arg LOCAL_CHROME=true to run Chrome in the
# container instead; that needs a seccomp profile that allows user
# namespaces (docs/HOSTED.md, "Local Chrome in a container").
FROM oven/bun:1-debian
# Pinned so a rebuild keeps a tested Bun. Bun 1.3.x fires no close on a client abort.
FROM oven/bun:1.4.3-debian

ARG LOCAL_CHROME=false
RUN if [ "$LOCAL_CHROME" = "true" ]; then \
Expand All @@ -19,7 +20,11 @@ RUN bun install --frozen-lockfile --production
COPY src ./src
COPY tsconfig.json ./

ENV SBM_BROWSER=browserbase \
# Tenants and their OAuth issuers come from a mounted file (docs/HOSTED.md,
# "Tenants"); SBM_PUBLIC_URL must also be set at run time. The development
# shared token (SBM_DEV_SHARED_TOKEN) is off in this image.
ENV SBM_TENANTS=/etc/sbm/tenants.json \
SBM_BROWSER=browserbase \
SBM_CHROME_PATH=/usr/bin/chromium \
SBM_CHROME_ARGS=--disable-dev-shm-usage \
SBM_HEADLESS=true \
Expand Down
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,21 @@ For a real-world walkthrough — an agent paying a Stripe test checkout with a c

## Development

| Path | What it holds |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| `src/broker/` | Secret refs, the `SecretsClient` interface, Turnkey and mock backends, destination-binding policy |
| `src/browser/` | Browser ownership and CDP secret injection |
| `src/redaction/` | The scrub layer every tool result passes through |
| `src/tools/` | One file per MCP tool |
| `test/fixtures/` | The demo storefront (login, checkout, receipt) |
| `docs/` | Design, threat model, hosted broker, Stripe demo walkthrough |
| Path | What it holds |
| ----------------- | ------------------------------------------------------------------------------------- |
| `src/broker/` | Secret refs, the `SecretsClient` interface, Turnkey and mock backends, pending fills |
| `src/browser/` | Browser ownership and CDP secret injection |
| `src/tools/` | One file per MCP tool |
| `src/browsers.ts` | Browser handles: browsers named by `browser_id`, bound to the caller that opened them |
| `src/auth/` | Per-tenant OAuth: tenant config, token checks, scopes, the 401 challenge |
| `test/fixtures/` | The demo storefront (login, checkout, receipt) |
| `docs/` | Design, threat model, hosted broker, Stripe demo walkthrough |

Destination-binding checks and the redaction layer that every tool result passes through come from [`@turnkey/browser-secrets`](https://www.npmjs.com/package/@turnkey/browser-secrets).

## Experimental isolated Lightpanda

See [the isolated worker design and offline test](docs/LIGHTPANDA.md). It uses
one externally provisioned worker per broker, a private Unix socket, and separate
network namespaces. The default example has no network access and uses synthetic
secrets only.
17 changes: 11 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 10 additions & 7 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,36 @@ Turnkey's export flow fits this exactly. `exportSecret` re-encrypts the secret t
`fill_secret(secret_id, element_uid)`:

1. Resolve the ref and the target element.
2. `BindingPolicy.assertAllowed(ref, target)` — see below.
2. Observe the target on the live page (`src/browser/observe.ts`) and decide with `authorize(ref, request, observation)` from `@turnkey/browser-secrets` — see below.
3. Optional human confirmation (see "MCP spec hooks").
4. `SecretsClient.exportSecret(ref)` — plaintext lands in broker memory.
5. Register the value and the field with the `RedactionRegistry` **before** injection.
6. Inject over CDP `Input.insertText`. Never through `Runtime.evaluate`.
5. Register the value with the `RedactionRegistry` **before** injection.
6. For each target: observe and authorize again, require the same destination (`isSameDestination`), register the target, then inject over CDP `Input.insertText`. Never through `Runtime.evaluate`.
7. Drop the value. Return outcome only: `{ filled: true }`.

## Destination binding

Static properties are bound immutably to a secret at import and are policy-visible. We reserve three keys (`src/broker/types.ts`):
Static properties are bound immutably to a secret at import and are policy-visible. `@turnkey/browser-secrets` parses them and makes the decision; its README lists every key. The main ones:

- `sbm:origin` — exact origin, required for a secret to be fillable
- `sbm:url-pattern` — optional URLPattern narrowing within the origin
- `sbm:selector` — optional CSS selector the target field must match
- `sbm:fields` — optional JSON map from payload key to selector, for a JSON secret with several parts

`authorize` trusts the observation it is given. The broker reads every fact in it from the browser where page script cannot change it: frame, document, URL and origin from `Page.getFrameTree`, and selector matches and shadow-root mode in an isolated world.

This is the prompt-injection defense. A malicious page can talk the agent into _requesting_ a fill. The fill still fails unless the live page matches the binding chosen by the human at import time.

Enforcement today is broker-side. The end state moves it into Turnkey's policy engine: a policy that evaluates the static properties makes the export itself unobtainable for the wrong destination, even with a stolen API key.

## Redaction

One choke point: `server.ts` pipes every tool result and every error through `scrub()`. Handlers cannot write to the transport.
One choke point: `server.ts` pipes every tool result and every error through the session's `RedactionRegistry` (`@turnkey/browser-secrets`). Handlers cannot write to the transport.

Two mechanisms:

- **Structural** (primary): fields that received a secret are tagged by element uid. Snapshot serializers elide their values. The network tool withholds request bodies for origins with live secrets. Screenshots mask tagged fields.
- **Value-scan** (backstop): every exported plaintext goes into a scrub-set. Any string in any output that contains a live value is replaced with `[REDACTED:<secretId>]`.
- **Structural** (primary): fields that received a secret are tagged by browser session, tab, frame, document and element uid. Snapshot serializers elide their values. The network tool withholds request bodies for origins with live secrets. Screenshots mask tagged fields.
- **Value-scan** (backstop): every exported plaintext goes into a scrub-set. Any string in any output that contains a live value is replaced with `[REDACTED:<secretId>]`. Values shorter than four characters are not scanned; their fields stay redacted by the tag.

Registration happens before the value touches the page, so no read can race ahead of redaction.

Expand Down
Loading
Loading