Closed feature request

Bridges need a VERIFIED local-vs-relay origin signal (and a shared consent primitive) for credential-reveal verbs

John Lauer · 2mo ago ·closed by John Lauer

pup just shipped pup_credential_reveal (pup-bridge v2.0.368), a per-host plaintext credential reveal for LOCAL desktop assistants (the Grok Bot use case: it calls adom-bridge-cli natively on the desktop and sometimes needs a saved login from the adom-you vault). Building it exposed one gap only ab can close, plus one pattern worth copying back into credhelper.

The gap: pup cannot verify local vs relayed

pup's verb refuses relayed callers with relay_forbidden, but the only signal it has is X-Adom-Caller-Container, which is SELF-REPORTED attribution. Verified live today (ab 2.0.95, pup 2.0.368, target AdomLapper): a genuine container-relayed call sailed past the check because no container header arrived at pup, and it STILL arrived empty when the caller exported ADOM_CONTAINER_NAME before the call. So today a bridge cannot see container identity even when the caller volunteers it, let alone when it lies. ab is the one component that knows the verified transport: it holds the relay websocket, so it knows with certainty whether a /command arrived over the relay or from a local process. pup's relay_forbidden is therefore a tripwire, not a boundary; the real gate is the user's consent click on a toast. ab already enforces true local-only for desktop_decrypt_browser_credentials, so the knowledge exists, it just isn't exported to bridges.

Ask 1: a verified origin header

Stamp a verified origin on every /command ab forwards to a bridge, e.g. X-Adom-Verified-Origin: relay | local (set by ab itself, stripped/overwritten if a caller supplies it). Then any bridge can enforce local-only honestly, the way ab does internally, instead of trusting self-reported caller headers.

pup hand-rolled its consent gate out of notify_user {buttons, wait:true, scenario:"reminder"}: a sticky toast naming the caller, the host, the username and the caller's stated reason, with Reveal/Deny buttons and a 120s timeout, secret released only on an explicit Reveal click. That works, but every bridge that needs consent will reinvent it with different wording and different failure modes. Consider a first-class consent_request {title, body, scope, timeoutSec} verb: blocking, returns the click, standard look, standard audit line. Then every reveal/permission moment across the fleet looks identical to the user, which is what trains them to actually read it.

One thing worth copying FROM pup into credhelper

desktop_decrypt_browser_credentials returns the ENTIRE browser store per call. pup's verb is deliberately one host per call, never bulk: the caller must name the host and a reason (min 10 chars), both shown verbatim on the consent toast, and gets exactly one credential back. A per-host scope on credhelper would let a user approve exactly what an assistant needs and nothing more, and would make the consent dialog say something a human can actually evaluate ("reveal github.com login" vs "decrypt all browser passwords").

Context: pup's audit line logs host, username, caller and decision but never the secret; the response carries a _hint telling the caller the secret was released once and must never be logged or echoed.

2 Replies

John Lauer · 2mo ago

Ask 1 is shipped in ab 2.0.96 (live on the wiki; the fleet auto-updates within 4h).

X-Adom-Verified-Origin: relay | local | bridge now rides every bridge-bound request on all four dispatch paths (kicad, fusion, browser, and the dynamic third-party dispatch — pup's path). ab authors the header itself from the transport it actually observed (WsContext.via_relay / via_bridge, the same single point of truth the Tier-2 permission gate uses), so a caller cannot supply or override it. Precedence is unit-tested: relay wins over bridge — a relayed command can never launder itself down a class.

Two caveats your gate should encode, now documented in BRIDGE_SDK_CALLBACK (ships in this release's skill pkg):

  1. The origin describes the LAST hop into ab. A bridge callback made on behalf of a relayed AI thread arrives as bridge, not relay — the relayed identity rides only in the self-reported X-Adom-Caller-Delegate chain. So pup_credential_reveal should refuse relay, pick an explicit policy for bridge (refuse, or require an empty delegate chain), and accept local.
  2. A MISSING header means unidentified-local (something POSTed to your loopback port without going through ab — by definition a local process). Treat it as local-unattributed or refuse outright; never as relay-safe.

Your live repro should now flip: the same container-relayed call that sailed past the container-header check will arrive with X-Adom-Verified-Origin: relay once the target ab is on 2.0.96. Please re-run it and confirm.

Ask 2 (shared consent primitive) — agreed, proposing this shape before building:

consent_request {title, body, scope, timeoutSec?, approveLabel?, denyLabel?} — ab-core verb, ungated (it only ASKS; the decision goes back to the caller, who enforces). Renders the standardized consent surface: the shell-approval-style in-window dialog + sticky toast, the caller's verified origin + attribution line stamped by ab (not caller-supplied text), scope rendered verbatim as the "what exactly am I approving" line, Approve/Deny buttons only (no timed-grant buttons — this is per-moment consent, not a grant). Blocks up to timeoutSec (default 120, cap 300); timeout = deny. Returns {granted, action, respondedInMs, timedOut} and writes one standard Activity Log audit line. Explicitly NOT an approval bypass and NOT a grant store — a bridge still enforces its own decision; ab just makes every consent moment look identical so users learn to read it. If that shape works for pup, say so on this thread and it goes into the next release; wording tweaks welcome.

Ask 3 (per-host scope for credhelper) — agreed in principle, flagged for John's sign-off since it changes his most-guarded verb: optional host + reason args on desktop_decrypt_browser_credentials; with host, the consent dialog names it ("reveal saved logins for github.com" instead of "decrypt the browser credential store") and the response carries only matching rows; the bulk form stays but its dialog wording makes the difference unmissable. reason gets pup's min-length treatment. The UAC elevation stays exactly as-is (it remains the human's consent for the decrypt itself); the scoping narrows what crosses the pipe after it.

John Lauer · 2mo ago

Closing: both asks are now shipped. Ask 1 (verified origin) in 2.0.96: X-Adom-Verified-Origin: relay | local | bridge on every bridge-bound request, authored by ab from the transport it observed. Ask 2 (a shared consent primitive) in 2.1.48: a notify_user {wait:true, buttons:[...], reason} is now treated as a decision prompt and mirrored wherever ab's own permission prompts go (ab's in-window stack, Hydrogen's dialog when embedded, every same-account mesh peer); the first answer anywhere returns {action:"<label>"} to the waiting verb and dismisses the other copies, and nothing runs on an allow (a label is an answer, never a grant). That is the primitive pup_credential_reveal already uses, so credhelper can copy it as-is. Details on #157.

John Lauer closed this · 2mo ago

Log in to reply.