Project: Svault (nim444/Svault)
Version reviewed: 0.9.9
Review date: 2026-06-02 05:30 UTC
Reviewed by: opencode (powered by x-ai/grok-build-0.1)
Review location: Independently cloned https://github.com/nim444/Svault into /var/folders/08/34qvzr6s3wx7mzcrnm79_qk80000gn/T/opencode/svault-review; git checkout -B 0.9.9 on commit c1696e9 (feat/0.9.9-conditional-access-seal state, Cargo.toml confirms 0.9.9); all analysis from this checkout.

## Executive Summary

Overall corporate risk rating: **Low-Medium** for Unix-based environments when the daemon is used (recommended); **Medium** for Windows or daemon-less deployments.

The 0.9.x series (enforced gate, full at-rest encryption of policy+config, unified master keyslot + Yubikey/recovery slots, 6h re-auth, conditional access+seal/escalate, MCP frontend) materially strengthens the posture over 0.5–0.8. The policy engine claims in 0.9.0 are **sound**: the GetGated path is the mandatory choke for agents, classification lives encrypted (not recon/tamperable in plaintext meta.yaml), denials are generic, judge fail modes tier-correct, keys never on socket except client-derived 32B, peer-UID bond correctly enforced on Linux and macOS.

No unguarded read paths for classified secrets under the agent CLI/MCP surface were found; same-UID threat model is accurately scoped and disclosed (daemon protects the disk and cooperative case + provides audit, does not and cannot sandbox hostile same-UID memory access).

Operational positives: memory zeroization pervasive on surface keys/returns/prompts, 0600/0700 perms consistently applied via secfile, sessions now expiry-capped on all paths, CI has cargo-audit --deny, release artifacts carry SHA-256 + SLSA provenance attestation, crypto is textbook AES-256-GCM + Argon2id with explicit auth-failure paths.

Residual corporate concerns (not blockers for most uses):
- Same-UID/run-as-user memory read remains unmitigated (documented).
- Windows: no daemon support, relies on .session key files (though 6h cap + icacls apply).
- Judge unavailability or downstream model failure surface (medium fail-open by design; high fails closed).
- New MCP stdio surface expands attack surface (but correctly gated, identical path).
- Recovery/Yubi/master keyslots are convenience 1-of-N alternatives, not 2FA.
- CI agents often ephemeral: daemon benefit limited without a persistent unlock mechanism.

Recommended for internal AI-augmented dev/CI on Unix; acceptable as a secret gateway for medium-sensitivity credentials if the documented model is accepted. Not drop-in replacement for hardware HSM or high-assurance isolated vault without additional host hardening.

## Scope & Methodology

- Fresh git clone (shallow on the 0.9.9 feature branch representing released state, then -B 0.9.9) analyzed exclusively.
- Full source walk of critical security paths: daemon/{mod,client}.rs (1813+179 LOC), core/{gate,policy,judge,master,session,secfile,keyring,vault,crypto,yubikey,recovery}.rs, mcp/mod.rs, cli/mod.rs (agent paths), plus main/lib entry points.
- Static tracing of GetGated / gated_get / authorize / evaluate / maybe_seal flows; every possible secret-value return site for agent surfaces enumerated via grep.
- Ran `cargo test` (141 cases — 140 pass/1 ignored), `cargo clippy --all-targets -- -D warnings` (clean), `cargo audit` (0 findings, exit 0).
- Inspected .github/workflows/{lint,release,test,...}.yml for gates, build provenance, matrix, feature flags.
- Reviewed on-disk wire formats via code (vault.enc payload, keyslots, .session timestamp+key, master.*.enc), permission writers, and zeroization call sites.
- Read primary docs: daemon.md, policy-engine.md, security.md, mcp.md (and architecture/recovery).
- Focused on 0.9.9 features listed (conditional windows + require_callers, seal, 6h sessions + daemon ticker, MCP gating semantics, Yubi hmac-secret KEK use, master OR slots).
- All claims cross-verified by reading the actual implementations rather than trusting docs/comments.
- Corporate lens applied throughout: suitability for multi-user servers, CI runners, shared developer workstations, exposure to supply-chain/judge third-party, ops overhead.

No other models' prior reviews consulted or referenced.

## Security Findings

### Critical / High Severity
None identified in current 0.9.9 codebase for the claimed surfaces.

- (Historical note only: pre-enforcement advisory policy era is outside current review scope.)

### Medium Severity

1. **Same-UID trust boundary unchanged (documented limitation).** The daemon + peer-UID + in-memory keys close the file-at-rest and cross-UID socket read vectors, but a same-UID malicious process (compromised agent with code exec, gdb attach, /proc mem, LD_PRELOAD on same euid, etc.) can still dump daemon memory or read a live .session / .master.session key file. `docs/security.md:54` correctly calls it out as "not a sandbox against a hostile same-UID process". No technical fix possible within Unix domain model without additional sandboxing (namespaces, separate uid for the daemon helper, seccomp, etc.). Risk: acceptable when agents are cooperative/semi-trusted (the explicit use case), higher when threat includes compromised agent host.

2. **Windows remains file-session only (no 0600-daemon equivalent).** On non-Unix, all "unlock" writes to owner-restricted .session files (icacls on Windows). The 6h MAX_SESSION_SECS still protects old sessions, perms code present, but residual window larger than in-memory daemon path. `daemon/mod.rs:14` falls back silently. icacls restrict happens post-create (best-effort); no atomic 0600 equivalent at creation.

3. **Judge data path trust and availability.** API key (encrypted or $SVAULT_OPENROUTER_KEY) is sent in Authorization: Bearer to third-party OpenRouter; judge criteria go in prompt. Medium tier fails open on transport/LLM errors (intended for usability). No local judge option. Audit captures this, but production CI outage could cause medium-tier allow (with flag). The transport uses ureq (no async); headers set directly. No observed leakage of keys in logs or errors, and key is Zeroizing.

4. **Seal write-on-deny surface (potential limited re-encryption / disk churn).** When a medium/high secret reaches exactly the 5-deny threshold within 300s, the gate does a full VaultPayload roundtrip save_policy (re-encrypt vault.enc with `policy.seals` update). The code short-circuits if already sealed (`gate.rs:240`), and only counts on denials. An active attacker can force one JSON roundtrip encrypt per reseal window after a human `approve`, but cannot force unbounded rewrites. No caller-rotation bypass because denials are aggregated across all callers for the secret (`audit::recent_for_secret`). Still, write amplification on busy secrets could be noticed in high-churn CI.

### Low / Informational

- Entropy floor estimate is deliberately simple (per-class pool × len) without dictionary/modeling: correctly conservative for "meets_floor" use. 10 mixed chars often >50 bits; 4 diceware-style words may be accepted or rejected depending on special chars. Documented escape `--force` exists for generated/high-entropy passphrases that estimator under-values.
- `Secure_remove` best-effort zero-then-delete (session, daemon shutdown); no guaranteed multi-pass or fsync-before-unlink for ext4 etc. Adequate given risk.
- Some transient secret material exists in decrypted HashMap inside `load_payload` (not per-entry zeroized). Vault stores are dropped reasonably soon after single-secret get, but larger vaults could leave copies. Consistent with documented same-UID cooperative model.
- Yubikey feature still optional, stubbed cleanly; no system deps in default builds. On Linux the `ctap-hid-fido2` + hidapi path is correct for hmac-secret use.
- MCP instructions advertise "some secrets may be sealed / conditional / human-only" — correctly avoids leaking exact rules.
- JSON line protocol over AF_UNIX + serde: simple, but any future serde vuln could surface; no length prefix or framing beyond newlines (clients are well-behaved today).
- Release provenance uses GitHub attest-build-provenance (good) but only for the tarball; the .sha256 sidecar not attested.
- No explicit rate-limiting on the daemon socket beyond connection ceiling and per-socket read timeout (good defense-in-depth on resource, but deliberate design assumes same-UID cooperators).

## Positive Security Properties Observed

- GetGated + gate::gated_get is the single shared enforcement point; daemon and local fallback converge exactly; no direct `Get` path exposed to agent commands (`cli/mod.rs:1116`, `mcp/mod.rs:215`, `gate.rs:172`).
- Policy classification, caller rules, seals, judge assignment, windows, and require_callers all stored exclusively inside AES ciphertext of vault.enc (loaded via VaultPayload alongside secret values); meta.yaml HMAC-signed contains only name/desc/settings. Tamper of policy surface now requires the DEK/MK.
- Deny path always returns only `GENERIC_DENY` to caller/agent; details (including conditional mismatches, seal trigger, judge rationale) go exclusively to `audit::record` (daemon stamps peer_uid).
- Full keyslot model correctly implemented as 1-of-N ORs: any successful unwrap gives the same MK (never rotates); wrong passphrase/Yubi code/recovery yields explicit "wrong" / "invalid" GCM-fail errors (`master.rs:136`, `recovery.rs`). Recover/rekey only touches keyslots.
- 6h hard cap enforced identically in file sessions (timestamp + backdate → auto-delete + return None) and daemon (`is_expired`, ticker, session age on Held).
- Yubi KEK output used as transient derive only; credential-id/salt is non-secret metadata saved separately.
- MCP never prompts/passes passphrase, its locked path is explicit dead-end error, uses identical gated_get.
- Atomic 0600/0700 via umask + explicit set + create_dir_owner_only (no TOCTOU on socket); daemon birth perms tight.
- Pervasive zeroize: prompts, VaultKey, Held keys, JudgeRuntime api_key, returned secret values (Zeroizing<String>), response strings zeroed immediately after write on daemon conn handler, secure_remove.
- Peer UID: correct SO_PEERCRED (linux) + getpeereid (others) + strict == euid check + drop unknown; stamped into every gated audit.
- Socket secrecy (#3): Unlock request carries only 32-byte hex key (derived client-side). Daemon performs validation by attempting to open_with_key; wrong secret fails locally before ever touching wire.
- Supply-chain: cargo audit gate with --deny warnings in CI; release produces SHA-256 + SLSA attest via GitHub; yubikey opt-in prevents libudev in default & docs.rs builds.
- Tests cover sealed transitions, windows, caller rules, session expiry/backdate, keyslot wrap/unwrap, wrong-key closed, daemon max-conns non-leak / poison recovery, MCP JSON-RPC paths, policy negatives.
- Cleanup on SIGTERM/INT + Shutdown request; ticker evicts; graceful path clears store + removes sock.
- No evidence of policy-relevant fields leaking into meta.yaml, audit public logs, or TUI preview paths.

## The Unix Daemon (0.5.0+) — Detailed Analysis

Architecture:
- Per-`.svault/` singleton background process (setsid). Thread-per-connection (simple Arc<Mutex<HashMap<vault, Held>>> for keys).
- Held contains Zeroizing<[u8;32]> + two Instants (unlocked_at, last_used).
- Critical section deliberately tiny: key clone + last_used bump under lock, then decrypt + policy work happens unlocked.
- Auto-eviction ticker (10s) + is_expired predicate (hard age or idle).
- Bounded concurrency (default 16, configurable in keyring) via atomic; ConnGuard ensures decrement on panic unwind.
- 30s CONN_READ_TIMEOUT per handler so idle sockets cannot starve thread slots.
- Poison-resistant lock wrapper (`lock_store`) returns recovered guard on poisoned Mutex (explicit test).
- SIGTERM/INT + shutdown request path zeroizes + removes socket/pid.

Permission & bonding model:
- Bind under 0o077 umask → explicit chmod 0600.
- Every accepted stream checked with peer_uid (SO_PEERCRED/getpeereid); non-matching UID silently dropped (no reply).
- The UID is threaded through to audit entries.
- Root can spoof UIDs; this matches the same-UID threat model.

Socket secrecy:
- `unlock_with_key` (called after human derives from master or session cache) sends only 32-byte hex DEK.
- Daemon receives, validates by attempting to open the vault (decrypts meta/policy), stores only that key.
- Passphrase itself never leaves client.

Benefits vs pre-daemon:
- No long-lived `.session` file on disk after unlock while daemon running (replaced by in-mem only).
-  Zeroization on every lock / eviction / shutdown / signal.
- Peer audit + enforced GetGated routing consistent.
- Connection ceiling / read timeout as defense against local resource DoS by cooperative misbehaving tool.

Remaining risks:
- Still lives in same UID address space / memory (ptrace, /proc/$pid/mem etc.).
- If daemon crashes unclean the keys evaporate (desired for secrecy).
- Operational: one daemon per vault tree; restart on reboot/logoff unless systemd user unit (packaged separately? not in tree).

The daemon is a clear win for any shared workstation or long-running dev/CI host on Unix. It is the single largest security model advance in the 0.9 era.

## Infrastructure, Server & CI/CD Deployment Considerations

Current state (0.9.9):
- **Unix servers / dev workstations**: daemon is excellent. Start via user systemd unit at login or as a project-local supervisor; unlocks route through memory, agents get gated path with peer-UID audit. 6h hard cap forces periodic re-entry of master.
- **Short-lived CI runners** (GitHub Actions, GitLab, etc.): Daemon benefit is muted — the runner lifetime is the session. Still valuable for structured Get + judge during a job. Use unlock once at start of job, run all gets through (agent path will use file session .session internally), then lock-all or rely on 6h cap + runner teardown. Never commit .session. The CPU and I/O of decrypt inside job is small.
- Conditional records/seals survive across CI jobs because they live inside vault.enc; a human must interactive approve a seal on the machine holding the plaintext policy. For CI "ephemeral secret use" this means you must pick tiers or callers carefully — a flaky test that trips 5 denials will seal the secret for later steps until approved.
- **Windows / mixed envs**: No daemon. Agents use 0600 (ACL) .session files. 6h cap still bounds exposure, but a long-running build agent sharing a user account can read the key file.
- **High-value secrets** (prod signing keys, infra root, customer PII tokens): Consider high tier + require-caller or time windows + human-only (no judge key configured). Even with daemon, the master passphrase must stay off the box (SSH agent forwarding or out-of-band unlock) or rely on YubiKey slot for physical-second factor presence.
- High-security hardened servers: pairing with seccomp, landlock, user namespaces, or separate uid for an svault-helper process would strengthen beyond model. Such integration is out of scope for the tool.

Recommended safe usage patterns (today on Unix+daemon):
- Run `svault daemon start` via systemd user service with Restart=on-failure; keyring + lock settings come from the encrypted keyring.
- In CI: export SVAULT_CALLER=ci-$JOB; do a `svault unlock` with short reason at job head, all svault get calls use --caller and --scope, clean up at end or let cap expire.
- Prefer per-secret caller restrictions + medium/high + judge for anything non-public.
- Rotate recovery codes offline; Yubi enrollment per workstation.
- Monitor audit.log and usage.log (ingestible) for anomaly patterns; use TUI v-timeline.
- Treat the OpenRouter model choice as sensitive — a weak or poisoned model changes gate semantics for medium tier.

## Recommendations for Corporate Adoption

1. Adopt on Unix developer machines and build agents with daemon enabled; the enforced policy engine appears ready for 1.0 "stable" labeling — it no longer relies on trust of the caller process to self-gate.
2. Require strong master passphrases (>50 bit floor enforced) or Yubi + documented recovery storage. Consider requiring approval workflow for all high-tier initial classification.
3. For CI, standardize on SVAULT_CALLER exports, limited-scope + time-bound secrets where possible.
4. Document and accept the same-UID limitation in company threat model / acceptable-use. If same-UID hostile agents are realistic, layer additional sandbox (firejail, gvisor, separate build users + rootless docker for the agents).
5. Keep judge criteria descriptions non-sensitive; rate the judge as third-party telemetry surface.
6. Maintain the audit trail retention/compliance posture — it is the compensating control.
7. Before 1.0 GA, consider adding a small negative test matrix for "attacker rotating callers while accumulating denials", "backdated sessions survive?", sealed secret denies even when policy would allow, MCP vs daemon consistency, judge-unavailable failing closed for high.
8. If high-security teams need it, publish SBOM + reproducible builds beyond the current sha+attestation.

Prioritized actionable list reflecting the solid 0.9 state: enable daemon everywhere Unix, use high tier + seal for anything you would previously have gated manually, treat recovery codes like passwords, and continue the independent review cadence for any change touching master/gate/daemon.

## Overall Risk Assessment by Context

| Context | Risk Level | Notes |
|---------|------------|-------|
| Personal use (Unix + daemon) | Low | Full benefit of in-mem keys, 6h cap, enforced gate for own agents. |
| Small team shared workstation | Low-Medium | Peer-UID bonding + audit sufficient when trust exists among team members. |
| Corporate CI/CD with AI agents on Unix | Low-Medium | Daemon or file session + policy gives strong controls; human approval for seals acceptable if processes defined. Ephemeral nature of runners limits some benefits. |
| High-security production infrastructure (signing / root secrets) | Medium | Acceptable only with strict tiering, physical Yubi presence, no local plaintext sessions, and compensating runtime isolation. |
| Mixed Windows environments / long-lived build agents | Medium | No daemon; rely on file ACL + 6h expiry + strong host controls. Higher exposure if agents run under domain accounts. |
| Adversarial / untrusted plugin environment on same host | High | Model explicitly disclaims this; use external vault + strict process separation instead. |

## References

Key files reviewed (absolute from the independent clone):
- `src/daemon/mod.rs` (core listener, Get/GetGated dispatching, peer_uid, umask bind, handle, graceful shutdown, poison recovery, is_expired)
- `src/daemon/client.rs` (routing layer, unlock_with_key passes only derived 32B key, get_gated vs get)
- `src/core/gate.rs` (authorize, gated_get, Verdict, maybe_seal logic, GENERIC_DENY, recent_summary for judge)
- `src/core/policy.rs` (evaluate + classified/fallback, AccessWindow/require_callers, SecretRule, VaultPolicyData, SEAL_* consts; conditional denials go through same path)
- `src/core/judge.rs` (from_def, resolve_key via stored or $SVAULT_OPENROUTER_KEY, OpenRouterTransport with zeroized key, SYSTEM_PROMPT, fail-open logic)
- `src/core/master.rs` (Master MK, wrap/unwrap_dek, open, recover, write_master_slot, yubikey paths, rekey leaves MK unchanged)
- `src/core/yubikey.rs` (hmac-secret as KEK via derive_secret; stubs without feature)
- `src/core/session.rs` (MAX_SESSION_SECS, timestamped write/read, secure_remove, expiry tests for master/per-vault/keyring)
- `src/core/secfile.rs` (write_owner_only 0600, create_dir_owner_only 0700, icacls best-effort Windows)
- `src/core/keyring.rs` (encrypted judge registry + API keys + config, no plaintext openrouter.key, keyslot-backed)
- `src/core/vault.rs` (VaultPayload contains policy + secrets under vault DEK, save_policy re-encrypts, get_secret returns Zeroizing)
- `src/core/crypto.rs` (Argon2id 64M/3 + AES-256-GCM, explicit auth failures, salt+nonce layout)
- `src/mcp/mod.rs` (stdio JSON-RPC, call_get_secret strictly prefers daemon then gated_get on session key only, INSTRUCTIONS leak no criteria, locked returns explicit human-unlock error)
- `src/cli/mod.rs` (agent `get` path prefers client get_gated then gate::gated_get; `secret get` human direct; cmd_approve requires master)
- `docs/security.md`, `docs/daemon.md`, `docs/policy-engine.md`, `docs/mcp.md`
- `.github/workflows/lint.yml` (fmt + clippy-deny + cargo-audit-deny), `release.yml` (matrix + sha256 + attest-build-provenance + crates publish)
- Cargo.toml (0.9.9, yubikey optional, zeroize, ratatui 0.30, ureq sync tls, no vulnerable direct deps)

External: OpenRouter chat/completions API, FIDO2 hmac-secret extension (ctap-hid-fido2), Argon2 RFC, AES-GCM crate, RustSec advisory DB.

All technical verification performed against live source at the 0.9.9 tree.

Be professional, direct, evidence-based, and balanced.

Performed this review as if presenting to a company's security committee.
