docs GitHub

ContributingDraft

Session credentials

Status: draft, for the lead and e2e. Depends on: docs/design/session-credentials.md (this file, now committed - sessions and the assistant team asked for a sha). Related: the assistant's delegated "give a session a login" power (work/assistant, b339af42), cohesion audit findings 1 and 5, the project column on grants (below, done this session).

What exists today

  • vault://item/field refs, resolved by vault.resolve and written into a file by vault.render (never printed).
  • vyre vault run --env-file and vault.inject: items into one child process's environment, on disk nowhere, person-only today (PEOPLE callers).
  • vault.grant {name, module, watcher?, project?} / ctx.vault.fetch: a module holds a standing grant, declared in its manifest's needs.vault or needs.credentials. This is process-wide access for code that is always running, not scoped to one session or one ask.
  • vault.need / needs_credential: a module's unmet needs, and the form to fill one (vault.connect).
  • Grants can now name one project (see below); nothing yet asks for a credential from inside a running session the way this doc describes.

Sessions are a different caller shape: short-lived, one per project (usually), asking for things interactively rather than declaring them once in a manifest. None of the above fits a session asking mid-conversation, "let me send this email" - that is the gap this doc closes.

1. A catalog a session can see (S)

vault.catalog {project?} (new, session callers; read: true, no presence): every item's id (stable, already exists as name... today an item's name doubles as its id and its display label; this introduces no new id scheme, it is the existing name), kind, and whether it is already usable in this project (granted, needs_credential, or unknown). Filtered to what a session could plausibly ask for - items whose kind is not passkey or ssh-key (those never leave the vault; a session cannot ask for them, full stop) and whose vault is agents (a locked personal item is not offered until it is open). Never a value, never a field name that would hint at one beyond the kind (a login has a password; that much is already public).

2. vault.request {item | kind, reason} (M)

A session calls this (through ctx.call, same path as any other tool) naming either a specific item id or a kind ("something that can send mail"), with a short reason string it composed (shown to the person, never sent anywhere else). It:

  • Refuses at once if the item does not exist or is a passkey/ssh-key.
  • If a grant already covers this module and this project (see the project column below), fills silently - no ask raised, just the audit row (release, source request). This is the no-nag rule: a repeat ask in the same session for the same item merges into the one open row instead of raising a second.
  • Otherwise raises one row through core/waiting (a new kind, credential, added to KINDS), with answer: {tool: "vault.request.answer", input: {id}} and fill: {scope, expiry}
    • the two things only the person can give. This is the same contract every other waiting row uses (cohesion's one list): it shows in the session as an inline ask card, in the assistant, the Capsule, the Deck, on the phone as a swipe, and on the vault page, and answering it in any one of them clears it everywhere (waiting.changed).
  • A title that never carries a value: "<session/agent> wants <item or kind> for <reason>", through core/waiting's existing clean() secret-filter as a second line of defence.

3. Approve with a scope (S, once vault.request exists)

vault.request.answer {id, decision: "approve"|"deny", scope?, expires?}, person-only. scope is one of once | session | project | agent (a repeatable pattern already used for approvals elsewhere in the vault) and becomes a grant: once never persists (the value is handed over and the ask is done); session is a grant scoped to a session id (needs sessions to hand vault a stable id per running session - coordinate the exact shape with sessions, not invented here); project uses the project column; agent is today's plain module grant, unscoped. Touch ID (presence) is asked only when the item is marked acts as me (see 4) or the scope is agent with no expiry - the broadest, most standing grants. Everything narrower (once, session, a project, or anything with an expiry) needs no Touch ID, per the no-nagging rule cohesion's interaction pass already set for reversible/cheap actions; a bad approval is undone with a revoke, not prevented with a dialog.

4. After approval: env var, or a placeholder through the egress proxy (L)

Two shapes, decided by the item's own acts as me flag (new, on kind: login/api-key/oauth items that can send mail, post, or pay - set at vault.put/vault.connect time from the provider's catalog entry, the same catalog connections.js already reads capabilities from):

  • A normal key (read-only APIs, most db-url/cloud/pat items): the real value, as an env var, filled at session start for items the project or agent already holds a grant for, and filled into the running session the moment vault.request is approved for anything asked for mid-session. Never written to disk; the system prompt is told the variable's name, this item's "how to use" line (already exists on some catalog entries as label/purpose text) and the approval policy in force, and is told plainly never to print the value.
  • An "acts as me" key (send mail, post, pay): the session gets a placeholder string instead of the real value (an opaque token, not a value-shaped string a leak would matter). A Vyre egress proxy - a small HTTP forward proxy the server session's outbound calls are routed through - recognises the placeholder in an outbound request, swaps in the real key on the way out, and (per a connector setting in the settings hub: always / only for sends / never) holds the request at the Gate until the person approves it, the same held-draft mechanism the Gate already has for messages. The system prompt mirrors the setting in force, so Claude behaves well even though the enforcement is the proxy's, not the prompt's - a prompt injection cannot talk the model out of a hold the proxy itself enforces.
    • What is and is not enforceable. A server session (vyre-agent) can be firewalled to force all egress through the proxy - no other path out. A session on a person's own Mac cannot be firewalled the same way without also touching that person's whole network stack; there, the proxy is the given path (the placeholder is useless anywhere else) but not the only possible one if the model's own sandbox is broken out of. Be honest about this in the UI: a Mac session's "acts as me" protection is "the real key never reaches the model", not "outbound traffic is physically unable to bypass the Gate."

5. Scrubbing (S, mostly built)

The PostToolUse hook already exists for other redaction; add every currently-open session's real values (and, for a placeholder, nothing - it was never real) to its scrub list, so a value that somehow reached a tool's output is caught before it reaches the transcript. This is a backstop, not the primary control; 1 and 4 exist so there is usually nothing to scrub.

The project column on grants (done this session, d. below is the sha)

Cohesion audit finding 1 (top-ranked): a grant was per-module only, so a teammate shared across two projects would carry one client's credentials into the other's the moment teammates ships. vault_grants now has a project column ("" = every project, matching how watcher already uses "" for "not applicable" - not NULL, which SQLite would never deduplicate against itself). The table was rebuilt (UNIQUE (item, module, watcher, project) widens the old UNIQUE (item, module, watcher)) the same way share.js's vault_held migration once did. vault.grant/vault.revoke/vault.release all take an optional project; vault.release (behind ctx.vault.fetch) filters by it when a caller passes one, but nothing passes one yet

  • ctx.vault.fetch is a module-scoped call (a module instance runs process-wide, not scoped to a project), so a teammate's actual project-scoped enforcement point is not this call at all. It is whatever new mechanism teammates/sessions use for a dynamically-identified agent's credential access (closer to vault.request above than to ctx.vault.fetch) - coordinate that mechanism's exact shape with sessions and teammates before teammates' migration step 1 starts, so it is built against a real project id from the start rather than retrofitted. vault_agent_grants (one agent signed in to one site, decision 2 - a different feature from module credential grants) got the column too, inert for now.

Sizes

ItemSizeNotes
Project column on grantsdoneschema, grant/revoke/release, CLI --project
1. CatalogSone read-only tool over existing item rows
2. vault.request + waiting rowMneeds core/waiting's credential kind (their file)
3. Approve with scopeSbuilds directly on existing grant/presence machinery
4a. Env var at approvalSctx.vault.fetch-adjacent, already mostly plumbing
4b. Placeholder + egress proxyLa new server process, Gate integration, firewall story
5. ScrubbingSextends an existing hook's list

For e2e

  • The placeholder scheme is the whole security model for "acts as me" keys: if a placeholder can be told apart from a value-shaped string, or the proxy's swap-in point can be reached from outside the session's own request path, the real key leaks. Wants a dedicated review once 4 is built, not folded into a general pass.
  • vault.request's no-nag merge (a repeat ask becomes the same open row) must not let a second, different reason silently reuse an approval meant for the first.
  • The egress proxy's connector setting (always/only for sends/never) must be read fresh per request, not cached at session start, so a person tightening it mid-session takes effect at once.
  • Scope agent with no expiry is the only path that still asks Touch ID; confirm every other path (once, session, project, or anything with an expiry) truly cannot be reached without it, matching cohesion's reversible-vs-secret split.