docs · docs/60-policy/SECURITY_POLICY.md · synced 2026-09-10

Security policy

what is gated where, and why a control never claims a gate the code does not run

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-Mode header, default normal, sourced from --mode (CLI) and VFLIN_MCP_MODE (MCP, env-scoped so an agent cannot elevate itself). Fail-closed on an unknown value. There is no session-scoped elevation and no system.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 delete profile/group (danger) — irreversible.
  • mass update across 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 like vault.set and 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 because profile.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 the profile.start route, precisely because an agent may call start at 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 generic vault.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 new orgPub' 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). Keep orgPriv in 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.