diff --git a/README.md b/README.md index c95b29c..399a064 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,33 @@ The bridge generates the password, types it into a page the agent has never scripted, and only then stores it. The agent gets a binding id back, which it can use for later fills. It never receives the value at any point. +**Real signup forms usually want more than username and password.** A date of +birth, an address, a phone number — required fields that have nothing to do +with the credential itself. `--field =` is repeatable and +covers exactly that: + +```bash +1claw-vault allow-signup ~/.1claw/vault.json \ + --id acme \ + --signup https://acme.example.com/signup \ + --login https://acme.example.com/login \ + --username ada@example.com \ + --hosts acme.example.com \ + --user-sel '#email' --pass-sel '#password' --submit-sel 'button[type=submit]' \ + --success-sel '.dashboard' \ + --field '#mobile=555-0100' \ + --field '#dob=1990-01-01' +``` + +Each field is typed the same way the username already is: plainly, in the +order given, by the bridge, in the same windowed page the password is typed +into. That is what keeps it out of the agent's context — the window, not +whether the value is treated as a secret. None of it is a rotating credential +that needs a `SecretHandle`'s zeroise-on-drop, so it lives in the policy +alongside the username, not in the vault's secret storage. The whole vault +file is still sealed at rest (AES-256-GCM, scrypt from your passphrase) +regardless of which part of it a value sits in. + **Committing is separate from typing, on purpose.** A password stored that the site never accepted produces a binding that will never work, and you find out weeks later when a login fails. So the bridge waits for the success signal you @@ -313,13 +340,15 @@ described — `--success-sel`, or the URL changing — and if it does not see on **cancels rather than commits**. `{"status":"rejected","reason":"no_success_signal"}` means nothing was stored. -**How this is tested.** Four tests drive a real Chromium against a real signup +**How this is tested.** Five tests drive a real Chromium against a real signup form that enforces a password rule and says no when it is not met: one asserts -the credential stored is byte-for-byte the one the site received, one that a -rejected password stores nothing, one that an unrecognisable outcome stores -nothing, and one that logs in afterwards with what was stored. Breaking the -verdict check so it commits regardless turns two of them red; storing a freshly -generated password instead of the typed one turns the other two red. +the credential stored is byte-for-byte the one the site received, one that +extra fields (a phone number, a date of birth) land in the real form fields +the policy names, one that a rejected password stores nothing, one that an +unrecognisable outcome stores nothing, and one that logs in afterwards with +what was stored. Breaking the verdict check so it commits regardless turns two +of them red; storing a freshly generated password instead of the typed one +turns the other two red. Five more go through `startBridge` and the MCP tool itself, because a path exercised only in pieces is a path nobody has run — that is exactly how a @@ -575,7 +604,7 @@ rejecting only cross-site `Origin`s. three times. The drivers get their own suites instead (`mock.test.ts`, `local.test.ts`, 14 tests each), and `saas` is covered end to end against production rather than by unit tests, since it needs a real vault to answer. -- **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. For HITL the client half is already there — `authorizeFill` may answer `awaiting_approval` and the bridge surfaces `get_approval_status` when a backend declares the `hitl` capability — but all three drivers report `hitl: false`, so nothing produces that answer yet. TOTP has no code at all +- **v0.2** — governed credential registration **(done, local backend)**, including extra required fields beyond username/password — DOB, address, phone — typed the same way the username is, via repeatable `--field =` **(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. For HITL the client half is already there — `authorizeFill` may answer `awaiting_approval` and the bridge surfaces `get_approval_status` when a backend declares the `hitl` capability — but all three drivers report `hitl: false`, so nothing produces that answer yet. TOTP has no code at all - **v0.3** — **cloud-runtime sidecar**: the same flow, unattended, inside a 1Claw runtime container. The bridge already does all of it on a laptop; what it needs is hosting. Two of the three obstacles are packaging (a browser in the image, a diff --git a/packages/browser-bridge/bin/1claw-vault.mjs b/packages/browser-bridge/bin/1claw-vault.mjs index add32bd..1a58a69 100755 --- a/packages/browser-bridge/bin/1claw-vault.mjs +++ b/packages/browser-bridge/bin/1claw-vault.mjs @@ -19,9 +19,17 @@ * 1claw-vault add --id --url --hosts a.com,.b.com * 1claw-vault list * 1claw-vault remove --id - 1claw-vault allow-signup --id --signup --login \\ - --username --hosts a.com --user-sel --pass-sel \\ - [--submit-sel ] [--success-sel ] [--error-sel ] + * 1claw-vault allow-signup --id --signup --login \\ + * --username --hosts a.com --user-sel --pass-sel \\ + * [--submit-sel ] [--success-sel ] [--error-sel ] \\ + * [--field = ...] + * + * --field is repeatable, for the other fields a real signup form asks for -- + * date of birth, an address, a phone number. Each is typed plainly by the + * bridge, the way the username already is: kept out of the agent's context + * because it is typed in the same windowed page as the password, not because + * it is treated as a secret. Order is preserved and matters only in that + * fields fill top to bottom the way you list them. * * The passphrase comes from ONECLAW_BRIDGE_VAULT_PASSPHRASE, or is prompted for * with echo off. Never from a command-line argument: argv is world-readable in @@ -45,6 +53,9 @@ if (process.env.ONECLAW_SUPPRESS_DEPRECATION !== "1") { const [cmd, file, ...rest] = process.argv.slice(2); const flag = (n) => { const i = rest.indexOf(`--${n}`); return i > -1 ? rest[i + 1] : undefined; }; +// Repeatable: --field a --field b -> ["a", "b"]. Order preserved, since +// extraFields are typed in the order given. +const flags = (n) => rest.reduce((acc, v, i) => (v === `--${n}` ? [...acc, rest[i + 1]] : acc), []); function usage(code = 2) { console.error(`usage: @@ -198,6 +209,16 @@ try { console.error("no success signal given; defaulting to --url-changes."); console.error("Pass --success-sel for a selector that appears only once the account exists."); } + // Other required fields the real form has beyond username/password -- + // DOB, address, phone, and the like. Each --field is "="; + // only the first "=" splits, so a value containing one (a URL, say) still + // works. Typed plainly, in the order given, by the bridge -- kept out of + // the agent's context the same way the username already is. + const extraFields = flags("field").map((f) => { + const eq = f.indexOf("="); + if (eq < 1) { console.error(`--field must be "=", got: ${f}`); process.exit(2); } + return { selector: f.slice(0, eq), value: f.slice(eq + 1) }; + }); const pass = await passphrase(); const doc = await load(pass); if (doc.registrations.some((r) => r.id === id)) { console.error(`${id} already allowed`); process.exit(2); } @@ -205,13 +226,14 @@ try { id, signupUrl: signup, loginUrl: login, username, allowedHosts: hosts, usernameSelector: userSel, passwordSelector: passSel, ...(flag("submit-sel") ? { submitSelector: flag("submit-sel") } : {}), + ...(extraFields.length ? { extraFields } : {}), success: { ...(successSel ? { selector: successSel } : { urlChanges: true }), ...(errorSel ? { errorSelector: errorSel } : {}), }, }); await save(doc, pass); - console.error(`allowed signup for ${id} as ${username}`); + console.error(`allowed signup for ${id} as ${username}${extraFields.length ? ` (+${extraFields.length} extra field${extraFields.length === 1 ? "" : "s"})` : ""}`); } 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, diff --git a/packages/browser-bridge/src/drivers/local-vault-file.ts b/packages/browser-bridge/src/drivers/local-vault-file.ts index 6871221..44efb30 100644 --- a/packages/browser-bridge/src/drivers/local-vault-file.ts +++ b/packages/browser-bridge/src/drivers/local-vault-file.ts @@ -49,6 +49,15 @@ export type RegistrationPolicy = { readonly usernameSelector: string; readonly passwordSelector: string; readonly submitSelector?: string; + /** + * Other fields the real signup form requires beyond username and password -- + * date of birth, an address, a phone number, and the like. Each is typed + * plainly by the bridge, the same way the username is: never handed to the + * agent, never a SecretHandle, but kept out of the agent's context by the + * same windowed page the password is typed in. Filled in order, before the + * password. + */ + readonly extraFields?: readonly { readonly selector: string; readonly value: string }[]; readonly success: { readonly urlChanges?: boolean; readonly selector?: string; diff --git a/packages/browser-bridge/src/drivers/local.ts b/packages/browser-bridge/src/drivers/local.ts index cabb1b5..ba19e4e 100644 --- a/packages/browser-bridge/src/drivers/local.ts +++ b/packages/browser-bridge/src/drivers/local.ts @@ -283,6 +283,7 @@ export class LocalVaultDriver implements VaultBackend { usernameSelector: policy.usernameSelector, passwordSelector: policy.passwordSelector, ...(policy.submitSelector ? { submitSelector: policy.submitSelector } : {}), + ...(policy.extraFields?.length ? { extraFields: policy.extraFields } : {}), success: policy.success, }; } diff --git a/packages/browser-bridge/src/registration-engine.ts b/packages/browser-bridge/src/registration-engine.ts index 9744f5c..03e0b98 100644 --- a/packages/browser-bridge/src/registration-engine.ts +++ b/packages/browser-bridge/src/registration-engine.ts @@ -84,6 +84,15 @@ export class RegistrationEngine { const before = await this.#url(sessionId); await this.#type(sessionId, grant.usernameSelector, grant.username); + // Other required fields the real form has -- DOB, address, phone, and + // the like. Typed the same way the username is: plainly, by the bridge, + // in this same windowed page, so an agent that could observe the + // password could observe these too, which is exactly what the window + // prevents. In order, before the password, matching a typical signup + // form's own top-to-bottom layout. + for (const field of grant.extraFields ?? []) { + await this.#type(sessionId, field.selector, field.value); + } handle = await takeSecret(grant.registrationId); // `use()` inside typeSecret has already zeroed the buffer; dropping the // reference stops `finally` from disposing an inert handle again. diff --git a/packages/browser-bridge/src/registration-real.test.ts b/packages/browser-bridge/src/registration-real.test.ts index 75b910a..0a87623 100644 --- a/packages/browser-bridge/src/registration-real.test.ts +++ b/packages/browser-bridge/src/registration-real.test.ts @@ -48,7 +48,7 @@ const siteAccepts = (pw: string) => pw.length >= 12 && /[^A-Za-z0-9]/.test(pw); let server: Server; let origin = ""; /** What the site actually received, so we can compare it with what was stored. */ -let received: { username?: string; password?: string } = {}; +let received: { username?: string; password?: string; mobile?: string; dob?: string } = {}; beforeAll(async () => { server = createServer((req, res) => { @@ -59,6 +59,8 @@ beforeAll(async () => { ${url.searchParams.get("error") ? '

Password too weak

' : ""}
+ +
`); @@ -70,7 +72,10 @@ beforeAll(async () => { req.on("end", () => { const form = new URLSearchParams(body); const password = form.get("password") ?? ""; - received = { username: form.get("email") ?? "", password }; + received = { + username: form.get("email") ?? "", password, + mobile: form.get("mobile") ?? "", dob: form.get("dob") ?? "", + }; // A real site rejects and re-renders; it does not silently succeed. const to = siteAccepts(password) ? "/welcome" : "/signup?error=1"; res.writeHead(302, { location: to }); @@ -153,6 +158,38 @@ describe.skipIf(!HAVE_CHROME)("registering against a real signup form", () => { } }, 120_000); + it("types extra fields a real form requires beyond username and password", async () => { + received = {}; + const { driver, transport, engine } = await setup( + policy({ + extraFields: [ + { selector: "#mobile", value: "555-0100" }, + { selector: "#dob", value: "1990-01-01" }, + ], + }), + ); + try { + const grant = await driver.beginRegistration({ siteId: "acme" } as never); + if (grant.kind !== "registration_grant") throw new Error("expected a grant"); + expect(grant.extraFields).toEqual([ + { selector: "#mobile", value: "555-0100" }, + { selector: "#dob", value: "1990-01-01" }, + ]); + + const outcome = await engine.register(grant as never); + expect(outcome).toMatchObject({ status: "registered", bindingId: "acme" }); + + // The real form actually received both -- not just accepted by the + // grant's shape, but typed into the real fields of a real page. + expect(received.mobile).toBe("555-0100"); + expect(received.dob).toBe("1990-01-01"); + expect(received.username).toBe("ada@example.com"); + expect(received.password).toBeTruthy(); + } finally { + await transport.close(); + } + }, 120_000); + it("stores nothing when the site rejects the password", async () => { received = {}; // A policy demanding a password this site will refuse: no symbols, and diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index c09c065..f930c57 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -181,6 +181,15 @@ export type RegistrationGrant = { readonly usernameSelector: string; readonly passwordSelector: string; readonly submitSelector?: string; + /** + * Other fields a real signup form requires beyond username and password -- + * date of birth, a mailing address, a phone number, and the like. Typed the + * same way the username is: plain values from the policy, never secrets in + * the SecretHandle sense, but typed by the bridge in the same windowed page + * as the password, so an agent that could observe one could observe all of + * them. Filled in the order listed, before the password. + */ + readonly extraFields?: readonly { readonly selector: string; readonly value: string }[]; /** How the bridge decides the site accepted the password. */ readonly success: RegistrationSuccess; };