feat: governed credential capture (a fill in reverse) + a full register→login→capture→execute example - #4
Conversation
… the vault, agent never sees it
A fill types a stored secret INTO a page. Capture is its mirror: while logged
in, the bridge reads a secret the site generates (an API key, a token) OUT of a
page and stores it in the vault, without the agent seeing it. Same invariant,
same shape as registration — the agent names only a pre-authorised site, and a
human authored the URL, the control that generates the value, and where it is
read from.
- protocol: `capture` capability + CaptureRequest/Grant/Decision/Outcome.
- capture-engine.ts: reads the value in a windowed page opened in the agent's
OWN context (so the site's logged-in session applies), with the agent's target
blocked so it cannot observe the read. The value is wrapped in a SecretHandle
the instant it exists and handed to the backend — never a tool result.
- LocalVaultDriver: beginCapture/commitCapture/cancelCapture, a captures map
loaded from the vault file, and `capabilities().capture` advertised only when
a human has written a policy. commitCapture writes the secret as a normal
vault entry, so a captured key is usable as a binding.
- `1claw-vault allow-capture` authors a capture policy.
- begin_credential_capture tool (site_id + the tab the agent is logged in on).
Tests: capture.test.ts (9, driver-level: policy gating, no-enumerate, no secret
in the grant, commit writes/encrypts, refuses overwrite) and capture-real.test.ts
(real Chromium, the full chain: register -> fill login -> capture an API key the
site issues, assert the vault holds exactly that key and the agent's results
never do). 264 tests, typecheck green.
examples/full-flow-capture.mjs runs all of it end to end and then USES the
captured key via examples/intent-executor.mjs — a local stand-in for the
Execution Intents API (POST /v1/agents/{id}/execute) that injects the vaulted
secret into a real request. The agent passes params, never the key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqPU5z6K3maS9J6LrcsEic
Found by driving the full loop against a real site (weatherapi.com). Three gaps the local tests could not surface, because their fixtures pre-filled the username and submitted a plain <form>: - **Fill types the username.** Many login forms do not pre-fill it, and a form submitted with only a password fails. VaultEntry/Grant gain optional `username` + `usernameSelector`; the engine types the username (not a secret) in the windowed page before the password, so the agent still never scripts the login. - **Fill clicks a named submit button.** An ASP.NET WebForms login posts back through the button; a bare form.submit() omits it and never authenticates. Optional `submitSelector` on the binding — the engine clicks it and stops, falling back to the generic Enter/requestSubmit only when it is absent. - **Capture reads an attribute.** A key shown next to a copy button lives in `data-clipboard-text`, not the element text (which drags in a label). CaptureSource/CapturePolicy gain optional `valueAttr`, read in preference to valueProp. All additive: absent fields mean the prior behavior exactly, so every existing fill/capture/registration test is unchanged. `1claw-vault add` gains --username/--user-sel/--submit-sel; allow-capture gains --value-attr. New real-Chromium test fill-login-fields.test.ts: a form with an empty username and a button-only submit (no <form>), asserting the agent's tab ends up authenticated. 265 tests, typecheck green. Verified end to end against weatherapi.com: automated signup (a plain math prompt, not a captcha) with email verification through AgentMail, then the bridge logged in without the agent seeing the password, captured the real API key off the dashboard without the agent seeing it, and a live request to api.weatherapi.com returned current weather — the agent holding only a city. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqPU5z6K3maS9J6LrcsEic
|
Validated the whole capability against a real third-party site, fully automated, no human in the loop and without defeating any anti-bot control:
That surfaced the three real-form gaps this push fixes (username fill, named-submit-button click, capture-from-attribute) — none of which the synthetic fixtures could show, because they pre-filled the username and submitted a plain Net: the full "create a login and store it → log in unseen → capture a generated key unseen → use the key" loop runs end to end against a real service. |
Review of #4. The capture engine blocks the agent's own target before any secret exists — the same control the fill engine has, for the same reason: a listener the agent installed *earlier* needs no CDP command during the window to observe the read, so the window has to open before the value exists rather than around the read itself. Nothing tested it. Deleting `gate.openFillWindow(agentTargetId)` left every capture test green: the suite proved the secret does not come back through the return value, and said nothing about whether the agent could simply watch it being read. Both windowing calls are covered now, and removing either turns this red. Also integrates the branch with BRIDGE-M2, which landed on main after it was opened: the observe contract gained four fields, and `VaultContents` required `captures`/`registrations` that `sealVault` already defaults — so every existing caller had to pass `captures: []` to say nothing. The type describes what the function accepts now. 266 tests, twenty against a launched Chromium. Co-authored-by: redbotster <redbotster@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019d5ks2kCa4eXD1ftdkECPu
An agent can now have a site-generated secret (an API key, a token) read off the page and stored in the vault without ever seeing it. The mirror of a fill, and it earns the same invariant the same way: the value is read in a target the agent has never scripted, the agent's own target is windowed first, and the value is wrapped in a SecretHandle the instant it exists. The agent gets an entry id. The agent supplies only a site id and the tab it is logged in on. The URL, the control that generates the secret, the selector it is read from and the vault entry it lands in all come from a human-authored policy — the same discipline registration uses, and the reason capture cannot be turned into a way to read arbitrary page content. Also carries three fixes a real end-to-end run exposed that the synthetic tests could not: the fill types the username (forms that do not pre-fill it), clicks a named submit button (ASP.NET-style logins post back through the button, so a bare form.submit() never authenticates), and capture can read an attribute (a key sitting in data-clipboard-text next to a copy button). All additive. Validated against a real service end to end, with no human in the loop. Co-authored-by: redbotster <redbotster@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_019d5ks2kCa4eXD1ftdkECPu
|
Merged as f9afe1c. This is a genuinely good piece of work, and the design is right where it matters most. The part I want to name explicitly, because it is the thing that makes capture safe rather than merely convenient: the agent supplies a site id and the tab it is logged in on, and nothing else. The URL, the control that generates the secret, the selector it is read from, and the vault entry it lands in all come from the human-authored policy. That is what stops "capture" becoming "read arbitrary page content into somewhere I choose" — and it is the same discipline registration already used, applied without being asked to. The engine earning the fill invariant the same way — window first, read in a target the agent has never scripted, wrap in a One thing I added. That windowing had no test. I deleted I also integrated the branch with BRIDGE-M2, which landed on main after you opened this: the observe contract gained four fields, and On the captcha boundary — you called this exactly right. Moving off OpenWeatherMap because its signup is a reCAPTCHA, rather than trying to get through it, is the correct instinct and I would have asked you to do the same. A human-check is a site saying it wants a human; defeating it is not a capability we build. What your weatherapi.com run proves is the useful thing: everything before and after that gate automates cleanly, and where a site puts one in the middle the flow stops and asks the user. I have written that into the docs as a stated boundary rather than leaving people to discover it. The three real-form fixes are the kind only a live run produces — a bare 266 tests, twenty against a launched Chromium. |
Asked for from outside: "create a login and store it, log in without the agent seeing, then generate an API key and store it in the vault without the agent seeing, then use that key." Registration + fill already cover the first two. This PR adds the missing direction — capture — and an example that closes the loop with an execution intent.
Capture: a fill in reverse
A fill types a stored secret into a page. A capture reads a site-generated secret (an API key, a token) out of a page and into the vault, while the agent is logged in, without the agent seeing it. Same invariant, same trust model as registration:
site_id(plus the tab it's logged in on, since a capture reads a secret behind that login). A human authored the URL, the control that generates the value, and where it's read from — so the agent chooses nothing about what gets stored.capture-engine.tsopens a windowed page in the agent's own browser context (so the site's session applies), with the agent's target blocked so it can't observe the read. The value is wrapped in aSecretHandlethe instant it exists and handed to the backend — it never becomes a tool result. The agent gets{status:"captured", entryId}.LocalVaultDrivergainsbeginCapture/commitCapture/cancelCapture;capabilities().captureis advertised only when a human has written a policy (absent-not-disabled, like registration). A captured secret is written as a normal vault entry, so it's immediately usable as a fill binding.1claw-vault allow-captureauthors the policy.Tests
capture.test.ts(9): the agent can't choose the source, a secret is never in the grant, cancel writes nothing, commit encrypts and refuses to overwrite.capture-real.test.ts(real Chromium): the whole chain — register → fill a login → capture an API key the site issues — and asserts the vault holds exactly the key the site handed out, encrypted, and that the key is in none of the agent's tool results.pnpm typecheckgreen, 264 tests pass (5 skipped without Chrome).The example, and step 3
examples/full-flow-capture.mjsruns register → login → capture, then uses the captured key viaexamples/intent-executor.mjs— a small local stand-in for the Execution Intents API (POST /v1/agents/{id}/execute): a binding says which vaulted secret and how to inject it; the agent passes params (a city), never the key. Output:The executor is deliberately not the production path — the hosted Intents API runs the request inside a TEE with guardrails and audit — but it shows the same property: the credential is used without the agent ever holding it. Wiring capture and the hosted Intents API to a single vault is the natural follow-up.
Note on real sites
Capture works against any site whose key lands in a readable element. Registration still can't do a site that requires email verification (as the README says), so on such a site you'd store the login with
1claw-vault add, let the bridge fill it, and capture the key — the capture path doesn't depend on registration.🤖 Generated with Claude Code