Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ rejecting only cross-site `Origin`s.
```

The community driver has landed too: `LocalVaultDriver`, an AES-256-GCM file keyed by scrypt from a passphrase you hold, with `1claw-vault` to manage it. Three backends now ship, and the adversarial suite is green on all of them — which was always the real bar.
- **v0.2** — governed credential registration **(done, local backend)**; HITL approval queue, TOTP fill, and registration on the hosted backend still to come
- **v0.2** — governed credential registration **(done, local backend)** and governed credential **capture** — a fill in reverse: while logged in, the bridge reads a secret the site generates (an API key, a token) in a windowed page and stores it in the vault, without the agent seeing it **(done, local backend; see `examples/full-flow-capture.mjs`)**; HITL approval queue, TOTP fill, and both on the hosted backend still to come
- **v0.3** — cloud-runtime sidecar (platform trust model)

## Security
Expand Down
53 changes: 49 additions & 4 deletions packages/browser-bridge/bin/1claw-vault.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ const flag = (n) => { const i = rest.indexOf(`--${n}`); return i > -1 ? rest[i +
function usage(code = 2) {
console.error(`usage:
1claw-vault init <file>
1claw-vault add <file> --id <id> --url <login-url> --hosts a.com,.b.com [--sso idp.com]
1claw-vault add <file> --id <id> --url <login-url> --hosts a.com,.b.com [--sso idp.com] [--username <u> --user-sel <css>]
1claw-vault list <file>
1claw-vault remove <file> --id <id>
1claw-vault allow-capture <file> --id <id> --url <page-url> --login <login-url> \\
--hosts a.com --value-sel <css> [--generate-sel <css>] \\
[--value-prop value|textContent] [--entry-id <id>]

hosts: a bare entry matches only itself; a leading dot ('.example.com') matches
that host and any subdomain. '*' is not a wildcard here and is refused.
Expand Down Expand Up @@ -68,7 +71,7 @@ async function readStdin() {

async function load(pass) {
const doc = await openVault(JSON.parse(await readFile(file, "utf8")), pass);
return { entries: doc.entries ?? [], registrations: doc.registrations ?? [] };
return { entries: doc.entries ?? [], registrations: doc.registrations ?? [], captures: doc.captures ?? [] };
}
async function save(doc, pass) {
// 0600: a vault the rest of the machine can read is not a vault.
Expand Down Expand Up @@ -96,7 +99,7 @@ try {
const pass = await passphrase();
const again = process.env.ONECLAW_BRIDGE_VAULT_PASSPHRASE ? pass : await promptHidden("again: ");
if (pass !== again) { console.error("passphrases do not match"); process.exit(2); }
await save({ entries: [], registrations: [] }, pass);
await save({ entries: [], registrations: [], captures: [] }, pass);
console.error(`created ${file}`);
} else if (cmd === "add") {
const id = flag("id"), url = flag("url");
Expand All @@ -114,7 +117,15 @@ try {
if (doc.entries.some((e) => e.id === id)) { console.error(`${id} already exists`); process.exit(2); }
const secret = await readStdin();
if (!secret) { console.error("empty secret"); process.exit(2); }
doc.entries.push({ id, secret, loginUrl: url, allowedHosts: hosts, ...(flag("sso") ? { ssoHosts: parseHosts(flag("sso"), "--sso") } : {}) });
doc.entries.push({
id, secret, loginUrl: url, allowedHosts: hosts,
...(flag("sso") ? { ssoHosts: parseHosts(flag("sso"), "--sso") } : {}),
// Optional username, for login forms that do not pre-fill it. Not a
// secret — the bridge types it before the password.
...(flag("username") ? { username: flag("username") } : {}),
...(flag("user-sel") ? { usernameSelector: flag("user-sel") } : {}),
...(flag("submit-sel") ? { submitSelector: flag("submit-sel") } : {}),
});
await save(doc, pass);
console.error(`added ${id}`);
} else if (cmd === "list") {
Expand Down Expand Up @@ -177,6 +188,40 @@ try {
});
await save(doc, pass);
console.error(`allowed signup for ${id} as ${username}`);
} else if (cmd === "allow-capture") {
// Authorising an agent to capture a secret the site generates (an API key,
// a token). Everything it could otherwise choose is fixed here: the page,
// the control that generates the value, and where the value is read from.
// The agent supplies only --id and the tab it is logged in on.
const id = flag("id"), url = flag("url"), login = flag("login");
if (!id || !url || !login) usage();
const hosts = parseHosts(flag("hosts"), "--hosts");
if (hosts.length === 0) { console.error("--hosts must name at least one host"); process.exit(2); }
const valueSel = flag("value-sel");
if (!valueSel) { console.error("--value-sel is required (where the secret is read from)"); process.exit(2); }
for (const [name, u] of [["--url", url], ["--login", login]]) {
if (!u.startsWith("https://") && !u.startsWith("http://127.0.0.1")) {
console.error(`${name} must be https (http is allowed only for 127.0.0.1)`);
process.exit(2);
}
}
const valueProp = flag("value-prop");
if (valueProp && valueProp !== "value" && valueProp !== "textContent") {
console.error("--value-prop must be 'value' or 'textContent'"); process.exit(2);
}
const pass = await passphrase();
const doc = await load(pass);
if (doc.captures.some((c) => c.id === id)) { console.error(`${id} already allowed`); process.exit(2); }
const entryId = flag("entry-id") || id;
doc.captures.push({
id, captureUrl: url, loginUrl: login, allowedHosts: hosts, valueSelector: valueSel,
...(flag("generate-sel") ? { generateSelector: flag("generate-sel") } : {}),
...(valueProp ? { valueProp } : {}),
...(flag("value-attr") ? { valueAttr: flag("value-attr") } : {}),
...(flag("entry-id") ? { entryId } : {}),
});
await save(doc, pass);
console.error(`allowed capture for ${id} -> vault id ${entryId}`);
} else usage();
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
Expand Down
4 changes: 3 additions & 1 deletion packages/browser-bridge/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ node packages/browser-bridge/examples/register-login-act.mjs
| --- | --- |
| `demo.mjs` | A fill happens and the agent's tool result is printed, so you can check the password is not in it. An off-host fill is denied. |
| `register-login-act.mjs` | The whole flow: the bridge signs up (generating the password), stores it encrypted, fills and submits a login so the **agent's own tab** ends up authenticated, and the agent then updates a profile as that user. The password is in none of it. |
| `agent.mjs` | The minimal CDP client the two above use to stand in for a framework. Not a demo on its own. |
| `full-flow-capture.mjs` | Adds the two remaining directions: **capture** (while logged in, the bridge generates an API key on the site, reads it, and stores it in the vault — a fill in reverse) and **execute** (the agent runs an intent that uses the captured key in a real request). The key is in none of the agent's output. |
| `agent.mjs` | The minimal CDP client the examples use to stand in for a framework. Not a demo on its own. |
| `intent-executor.mjs` | A local stand-in for the 1Claw Execution Intents API (`POST /v1/agents/{id}/execute`): a binding says which vaulted secret and how to inject it; the agent passes params, never the secret. Used by `full-flow-capture.mjs`. Not the production path — the hosted Intents API runs inside a TEE with guardrails and audit — but it demonstrates the same property. |

`register-login-act.mjs` prints each step:

Expand Down
120 changes: 120 additions & 0 deletions packages/browser-bridge/examples/full-flow-capture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env node
// Copyright (C) 2026 1Claw
// SPDX-License-Identifier: Apache-2.0

/**
* The whole thing, and the agent sees no secret at any step:
*
* 1. register — the bridge signs up and generates the password
* 2. log in — the bridge fills and submits; the agent's tab is authed
* 3. capture — while logged in, the bridge generates an API key on the
* site, reads it, and stores it in the vault
* 4. execute — the agent runs an intent that uses the captured key in a
* real request; a local executor injects it, the agent gets
* the response
*
* Steps 1-3 are browser-bridge. Step 4 is a stand-in for the 1Claw Execution
* Intents API (see examples/intent-executor.mjs): the credential is used
* without the agent ever holding it.
*
* node examples/full-flow-capture.mjs [--chrome /path/to/chrome]
*/
import { createServer } from "node:http";
import { existsSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startBridge, LocalVaultDriver, sealVault, openVault } from "../dist/index.js";
import { Agent } from "./agent.mjs";
import { executeIntent } from "./intent-executor.mjs";

const argv = process.argv.slice(2);
const flag = (n) => { const i = argv.indexOf(`--${n}`); return i > -1 ? argv[i + 1] : undefined; };
const CHROME =
flag("chrome") ?? process.env.ONECLAW_BRIDGE_CHROME ??
{ darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", linux: "/usr/bin/google-chrome" }[process.platform];
if (!CHROME || !existsSync(CHROME)) { console.error(`No Chromium at ${CHROME ?? "(unknown)"}. Pass --chrome.`); process.exit(2); }
const PASSPHRASE = "a-long-enough-demo-passphrase";

// ── A site: signup, login, an API-keys page, and a key-protected endpoint ────
const users = new Map();
let issuedKey = "";
const bodyOf = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b)); });
const site = createServer(async (req, res) => {
const u = new URL(req.url, "http://x");
const signedIn = (req.headers.cookie ?? "").includes("session=ada");
if (u.pathname === "/signup" && req.method === "GET")
return void res.end(`<!doctype html><body><form action="/signup" method="post"><input id="email" name="email"><input id="password" name="password" type="password"><button id="go" type="submit">Go</button></form></body>`);
if (u.pathname === "/signup" && req.method === "POST") { const p = new URLSearchParams(await bodyOf(req)); users.set(p.get("email"), p.get("password") || ""); return void res.writeHead(302, { location: "/welcome" }).end(); }
if (u.pathname === "/welcome") return void res.end("<!doctype html><h1 id=ok>Welcome</h1>");
if (u.pathname === "/login" && req.method === "GET")
return void res.end(`<!doctype html><body><form action="/session" method="post"><input id="username" name="username" value="ada@example.com"><input id="password" name="password" type="password"></form></body>`);
if (u.pathname === "/session" && req.method === "POST") { const p = new URLSearchParams(await bodyOf(req)); if (users.get(p.get("username")) !== p.get("password")) return void res.writeHead(302, { location: "/login?bad=1" }).end(); return void res.writeHead(302, { location: "/account", "set-cookie": "session=ada; Path=/" }).end(); }
if (u.pathname === "/account") return void res.writeHead(signedIn ? 200 : 401, { "content-type": "text/html" }).end(`<!doctype html><body><div id="who">${signedIn ? "ada@example.com" : "anonymous"}</div></body>`);
if (u.pathname === "/settings/api") {
if (!signedIn) return void res.writeHead(401).end("no");
return void res.writeHead(200, { "content-type": "text/html" }).end(`<!doctype html><body><input id="api-key" value=""><button id="generate">Generate</button><script>document.getElementById('generate').addEventListener('click',async()=>{const r=await fetch('/issue-key',{method:'POST',credentials:'include'});document.getElementById('api-key').value=(await r.json()).key;});</script></body>`);
}
if (u.pathname === "/issue-key" && req.method === "POST") { if (!signedIn) return void res.writeHead(401).end("no"); issuedKey = "sk_live_" + Math.abs(Date.now() ^ (Math.random() * 1e9 | 0)).toString(36); return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ key: issuedKey })); }
// A key-protected "weather" endpoint — the thing step 4 calls with the key.
if (u.pathname === "/api/weather") {
if (u.searchParams.get("key") !== issuedKey) return void res.writeHead(401).end('{"error":"bad key"}');
return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ city: u.searchParams.get("q"), tempC: 17 }));
}
res.writeHead(404).end();
});
await new Promise((r) => site.listen(0, "127.0.0.1", r));
const origin = `http://127.0.0.1:${site.address().port}`;

// ── Vault: a signup policy and a capture policy, both human-authored ─────────
const dir = mkdtempSync(join(tmpdir(), "1claw-fullcap-"));
const vaultPath = join(dir, "vault.json");
writeFileSync(vaultPath, JSON.stringify(await sealVault({
entries: [],
registrations: [{ id: "acme", signupUrl: `${origin}/signup`, loginUrl: `${origin}/login`, username: "ada@example.com", allowedHosts: ["127.0.0.1"], usernameSelector: "#email", passwordSelector: "#password", submitSelector: "#go", success: { urlChanges: true } }],
captures: [{ id: "acme-key", captureUrl: `${origin}/settings/api`, loginUrl: `${origin}/login`, allowedHosts: ["127.0.0.1"], generateSelector: "#generate", valueSelector: "#api-key", valueProp: "value" }],
}, PASSPHRASE)));

const backend = new LocalVaultDriver({ path: vaultPath, passphrase: PASSPHRASE });
await backend.open();
const bridge = await startBridge({ executablePath: CHROME, backend, host: "127.0.0.1", port: 0, args: ["--headless=new"] });
const observe = (frameId) => () => ({ tabOrigin: origin, frameOrigin: origin, formActionOrigin: origin, frameId, generation: 0 });

try {
console.log(`\n site: ${origin}\n bridge: ${bridge.url}\n tools: ${bridge.tools.map((t) => t.name).join(", ")}\n`);

const reg = await bridge.callTool("begin_credential_registration", { site_id: "acme" }, observe("acme"));
console.log(` 1. register -> ${JSON.stringify(reg)}`);

const agent = await Agent.connect(bridge.url);
const { targetId, sessionId } = await agent.openTab(`${origin}/account`);
const who = async () => { for (let i = 0; i < 60; i++) { const v = await agent.evaluate(sessionId, "document.querySelector('#who')?.textContent ?? ''"); if (v) return v; await new Promise((r) => setTimeout(r, 150)); } return ""; };
console.log(` 2. before login -> ${await who()}`);

const fill = await bridge.callTool("request_fill", { binding_id: "acme", target_id: targetId, selector: "#password" }, observe(targetId));
await agent.reload(sessionId);
console.log(` login fill -> ${JSON.stringify(fill)}; agent tab now: ${await who()}`);

const cap = await bridge.callTool("begin_credential_capture", { site_id: "acme-key", target_id: targetId }, observe(targetId));
console.log(` 3. capture key -> ${JSON.stringify(cap)}`);

// 4. Execution intent: the agent asks to run a request that needs the key.
// It passes params (the city), never the key. The executor injects it.
const result = await executeIntent({
vaultPath, passphrase: PASSPHRASE,
binding: { method: "GET", url: `${origin}/api/weather?q={{city}}`, secretEntryId: "acme-key", inject: { as: "query", name: "key" } },
params: { city: "London" },
});
console.log(` 4. execute intent -> HTTP ${result.status}, body ${result.body}`);

const key = openVault(JSON.parse(readFileSync(vaultPath, "utf8")), PASSPHRASE).then((d) => d.entries.find((e) => e.id === "acme-key")?.secret);
const storedKey = await key;
const agentSaw = [reg, fill, cap, result].some((r) => JSON.stringify(r).includes(storedKey));
const ok = reg.status === "registered" && fill.status === "filled" && cap.status === "captured" && result.status === 200 && JSON.parse(result.body).tempC === 17 && !agentSaw;
console.log(`\n agent ever saw the key: ${agentSaw ? "YES — BUG" : "no"}`);
console.log(` ${ok ? "OK" : "FAILED"}: registered, logged in, captured a key, and used it — the agent never saw it\n`);
agent.close();
process.exitCode = ok ? 0 : 1;
} finally {
await bridge.close();
await new Promise((r) => site.close(r));
}
55 changes: 55 additions & 0 deletions packages/browser-bridge/examples/intent-executor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (C) 2026 1Claw
// SPDX-License-Identifier: Apache-2.0

/**
* A minimal local stand-in for the 1Claw Execution Intents API
* (`POST /v1/agents/{id}/execute`), so the capture examples can close the loop
* without a hosted account.
*
* The shape is the platform's: a human authors a binding — which vaulted secret,
* and how it is injected into one outbound request. The agent calls
* `executeIntent` with the binding id and some params, and gets the response
* back. It never sees the secret. The executor holds the vault passphrase, the
* way the hosted runtime holds the key inside the TEE, and injects the secret
* itself.
*
* This is a demonstration executor, not the production path — the hosted Intents
* API runs the request inside a TEE with guardrails, rate limits, and audit.
* What it shows is the same property: the credential is used without ever
* reaching the agent.
*/
import { readFileSync } from "node:fs";
import { openVault } from "../dist/index.js";

/**
* @param {object} o
* @param {string} o.vaultPath path to the encrypted vault
* @param {string} o.passphrase its passphrase (the executor is trusted)
* @param {object} o.binding { method?, url, secretEntryId, inject }
* inject: { as: 'query', name } | { as: 'header', name, template? }
* `url` and header `template` may contain {{param}} and, for a header,
* {{secret}} — the only place the secret is ever substituted.
* @param {Record<string,string>} [o.params]
* @returns {Promise<{status:number, body:string}>}
*/
export async function executeIntent({ vaultPath, passphrase, binding, params = {} }) {
const doc = await openVault(JSON.parse(readFileSync(vaultPath, "utf8")), passphrase);
const entry = doc.entries.find((e) => e.id === binding.secretEntryId);
if (!entry) throw new Error(`no vaulted secret "${binding.secretEntryId}"`);
const secret = entry.secret;

const fill = (s) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => encodeURIComponent(params[k] ?? ""));
let url = fill(binding.url);
const headers = {};

if (binding.inject.as === "query") {
const u = new URL(url);
u.searchParams.set(binding.inject.name, secret); // the secret goes on the wire, never to the agent
url = u.toString();
} else {
headers[binding.inject.name] = (binding.inject.template ?? "{{secret}}").replace("{{secret}}", secret);
}

const res = await fetch(url, { method: binding.method ?? "GET", headers });
return { status: res.status, body: await res.text() };
}
1 change: 1 addition & 0 deletions packages/browser-bridge/src/adversarial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function backend(): VaultBackend & { asked: FillRequest[] } {
capabilities: () => ({
fills: true,
registration: false,
capture: false,
checkout: false,
signing: false,
hitl: false,
Expand Down
Loading
Loading