Purpose: keep powerful features powerful, but make dangerous actions gated, auditable, and reversible where possible. We do not cripple capability for “safety theater”; we add levels, audit, and a green read that shows the blast radius. (Revised by ADR-0036.)
1. Policy modes
| Mode | For | Allows |
|---|---|---|
normal |
default, everyday | profile CRUD, start/stop, automation runs, sync |
power |
advanced operators | bulk/mass operations, cookie/profile export, migration import |
danger/admin |
explicit, rare | hard delete, vault secret read, browser.eval, destructive task ops |
- Mode is session-scoped, explicitly set (
system.policy.set), and logged on change. - A call requiring a higher mode than the session is refused with a clear “how to elevate”.
- Mode is a per-request
X-Vflin-Modeheader, defaultnormal, sourced from--mode(CLI) andVFLIN_MCP_MODE(MCP, env-scoped so an agent cannot elevate itself). Fail-closed on an unknown value. There is no session-scoped elevation and nosystem.policy.set. (ADR-0036)
2. Dangerous actions registry
Every action below MUST: declare a required mode, emit an Action Log entry, and name the green read that shows its blast radius — or record why none exists (ADR-0034:131, ADR-0036). There is no dry-run where feasible.
export cookies/export profile(power) — exfiltratable session material.read vault secret(danger) — plaintext secret exposure.hard deleteprofile/group (danger) — irreversible.mass updateacross many profiles (power) — wide blast radius.browser.eval/ arbitrary in-page code (danger) — arbitrary execution.migration import(power) — ingests untrusted external data → validate + sandbox.materialize → raw path(power) — a filesystem path bypasses the control boundary; remote clients get an opaque handle, raw path only to the local UI/owner, always audited.- destructive task operations (danger).
profile.session.set(danger) — ingests account-grade material (cookies, session tokens, passwords, TOTP seeds) into the vault. Secrets travel over loopback likevault.setand are never read back.profile.session.clear(danger) — irreversible: removes the vaulted secrets, not just the binding. If the vault held the only copy of a token or seed, it is gone. Dry-run is not offered separately becauseprofile.session.get(green) already shows exactly which refs a clear would wipe.- launch-time session application — the use of that material, and the reason the two entries above are
not the whole story:
session.inject(normal) — restores the profile’s OWN saved browser state. Audited per profile+service so a start that unsealed a session is distinguishable from opening an empty profile.session.login(danger) — drives a real login form and submits a stored password + a generated 2FA code to a third party. The required mode is computed from the profile’s bindings, not from theprofile.startroute, precisely because an agent may callstartat normal (SESSION_LOGIN_MODE_REQUIRED). Automation auto-launch is treated as normal, so it can never trigger a first credential submission on its own.
- module-owned vault namespaces (
session/,wallet/) are refused by the genericvault.set(VAULT_NAMESPACE_RESERVED). They are written only by their own danger-gated operations — otherwise a normal-mode caller could substitute the credential a later launch injects, defeating that gate one level down.
3. Audit (Action Log)
- Append-only, tamper-evident via a hash chain (each entry carries the hash of the prior → any edit/removal breaks the chain), with an optional periodic external anchor for teams/compliance. Per actor (user/CLI/MCP-agent): action, target, mode, args-summary (secrets redacted), result, timestamp. Synced to cloud for teams.
- Reads of the log are themselves logged when in team/compliance context.
4. Key management (zero-knowledge, multi-KEK — ADR-0008)
- Envelope encryption: per-profile random Data Key (DEK) wrapped by multiple independent KEKs — any one unwraps. Defaults: password (Argon2id) + mandatory Recovery Key; optional passkey (WebAuthn PRF) + OS keychain; team profiles add an org-key KEK (admin-assisted recovery). Server stores only ciphertext + wrapped-DEK copies.
- Server never sees plaintext keys or content, including during recovery — no server-side escrow. Lose all factors = data loss (honest E2EE floor, now multi-factor).
- Argon2id params tuned high for desktop (OWASP-plus; exact numbers TBD); per-profile DEKs limit blast radius and enable per-profile sharing. See KEY_RECOVERY_COMPARISON.md.
- Team org-KEK is a recovery anchor and a single point of compromise. GA custody (ADR-0028): a single
Admin holds
orgPriv— the accepted GA residual is single-point-of-compromise on an Admin; threshold/Shamir (no single holder) is a deferred later hardening op, not a GA requirement (rescoped by ADR-0028; the server + ADR-0017 were not built for Shamir). Revocation = rotate the org keypair + re-wrap the shared DEKs to the neworgPub'on member removal / owner change — this is a forward-cut re-seal (a removed member gets no future access); the true backward-cut (re-key the underlying DEKs/blobs, O(all-shared)) is deferred (ADR-0028 decision 4). KeeporgPrivin the Admin’s client custody — never the server. No server escrow, ever (even under support pressure); the safeguard is forcing Recovery-Key-stored confirmation at setup. (PHASE0_REVIEW M2; ADR-0028.) - Team-share READ = DEK-authenticity (ADR-0029). A member reads a profile shared with their team by opening the
blob with the org-derived DEK, relying on the AEAD tag + BLAKE3 content-hash — NOT the owner’s Ed25519
signature (cross-account verify-before-decrypt is structurally impossible: the member has no entry for the owner’s
device key). The cloud cannot forge a blob that opens under the DEK (it never holds the DEK), so zero-knowledge
- cloud-substitution integrity hold. Accepted residual: a malicious team member holding the DEK could substitute a blob for other members — an insider threat within a mutually-trusting team (each member can already read/leak the content); closing it needs authenticated cross-account per-device keys the model does not distribute, and an owner-key carried in the share would come from the same unauthenticated directory (ISSUE-029).
5. Secret hygiene
- Secrets only via the Vault, referenced by key. Never in script source, logs, error messages, blob manifests, or MCP/CLI output. Redaction enforced centrally in the Local API layer.
6. Local API exposure
- Loopback-only by default; per-session local token. No remote binding without explicit, documented opt-in (and never as a default).
7. Supply chain
- Pin & verify dependencies; sign our artifacts (app, CLI, cores). Cores verified before launch.
- Node module sidecar: third-party npm runs with least privilege; no implicit vault/fs/keyring/API-token access — capability-scoped RPC + an isolation ladder, strictest tier for the untrusted Hub (ADR-0022).
Open questions
Audit-log tamper-evidence mechanism, whether browser.eval ships in v1 → OPEN_QUESTIONS.md.