**Project**: Svault (nim444/Svault)
**Version reviewed**: 0.9.9 (`Cargo.toml` confirmed `version = "0.9.9"`, branch `feat/0.9.9-conditional-access-seal`)
**Review date**: 2026-06-02
**Reviewed by**: Claude (Opus 4.8)
**Review location**: Reviewed in place against the local working tree of the `feat/0.9.9-conditional-access-seal` branch (~16.4k LOC). Ran `cargo test` (140 pass, 1 ignored), `cargo clippy --all-targets` (clean), and `cargo audit` (no advisories, exit 0).

## Executive Summary

Svault 0.9.9 is a well-engineered, defensively-coded Rust secret manager whose cryptographic core (AES-256-GCM + Argon2id, authenticated everywhere, keys zeroized on drop) is sound, and whose at-rest posture is strong: the **entire policy surface is now encrypted inside `vault.enc`**, `meta.yaml` leaks no classification, and a unified master-keyslot model (OR-not-2FA) cleanly separates the passphrase from the data keys. The Unix daemon is the right architecture — keys in memory behind a `0600` socket with a correct peer-UID bond, a connection ceiling, read timeouts, poison recovery, and graceful zeroizing shutdown.

**Overall risk rating for medium-company use: LOW–MODERATE on Unix, MODERATE on Windows/CI**, with the headline caveat that the agent gate (policy + AI judge + seal/escalate + conditional access) is **honestly and explicitly an advisory control for cooperative/semi-trusted agents plus audit — not a security boundary against a hostile same-UID process.** That boundary is real and unavoidable given the design, and the project documents it clearly. My findings do not break the crypto or the at-rest story; they concern (1) the ungated human read path that sits beside the gate, (2) rate/burst limiting keyed on a self-asserted caller, and (3) the AI judge being a soft, prompt-injectable control. The daemon meaningfully *improves* the posture (no key on disk, audited choke point) and does not introduce a remotely-reachable attack surface.

## Scope & Methodology

Source reviewed in depth: `src/core/{crypto,master,session,vault,meta,policy,gate,judge,secfile,recovery}.rs`, `src/daemon/{mod,client}.rs`, `src/mcp/mod.rs`, and the CLI dispatch in `src/cli/mod.rs` (`cmd_get`, `cmd_secret`, `cmd_approve`, `open_unlocked_or_prompt`). CI/release reviewed: `.github/workflows/{lint,release}.yml`. Tooling: `cargo test`, `cargo clippy --all-targets`, `cargo audit`. I traced both enforced read paths end-to-end (daemon `GetGated` and the CLI/MCP local `gate::gated_get`) and the unenforced human paths (`Get` / `svault secret get`).

## Security Findings

### Critical / High Severity

**None.** No unauthenticated decryption, no key written to a world-readable location, no path that returns a classified secret to the *agent* gate without running it, no tier/scope tamper without the key (policy is inside the GCM-authenticated `vault.enc`; `meta.yaml` is HMAC-signed with the DEK — `vault.rs:544 meta_yaml_leaks_no_classification_at_rest` and `meta.rs:168 load_verified` confirm both).

### Medium Severity

**M1 — The human read path bypasses the entire gate (by design, but it is the dominant risk).**
`Request::Get` (`daemon/mod.rs:273`) and `svault secret get` (`cli/mod.rs:982`) return **any** secret value — including `high`-tier and **currently-sealed** secrets — with no policy, no judge, no window check, and no seal check. They require only that the vault is unlocked (cached `0600` session or daemon-held key) and, for the daemon, that the peer UID equals the daemon's. This means every 0.9.9 control headlined in the prompt (judge, tiers, conditional windows, required-callers, seal/escalate) is enforced *only* on the agent path and is **trivially sidestepped by a same-UID process that simply calls the human path instead.** A sealed secret is not protected from `svault secret get`. This is the documented same-UID trust model (`master.rs:21-24`), and it is a defensible boundary — but the marketing-adjacent framing of "seal denies every agent get until a human clears it" should everywhere be read as *cooperative agent*. Recommendation: keep the honest scoping prominent in `docs/security.md`, and consider gating the daemon's `Get` behind an explicit human-presence signal in a future version.

**M2 — Rate limiting and burst detection are evadable by rotating the self-asserted `--caller`.** `policy::rate_and_burst` (`policy.rs:486`) and the judge's "recent activity" anomaly signal (`gate.rs:284 recent_summary`) both count audit entries **keyed on the caller string**, which the agent supplies (`--caller` / `$SVAULT_CALLER`, `cli/mod.rs:1107`). An agent can issue unlimited requests by cycling caller identities, defeating both the rate limit and the burst signal fed to the judge. The 0.9.9 **seal counter is correctly immune** — `audit::recent_for_secret` (`gate.rs:244`) counts denials **per-secret across any caller**, so the prompt's concern (d) holds. But the per-caller rate/burst controls do not. Net effect: caller rotation degrades the judge's context and removes rate limiting; the seal remains the only caller-agnostic backstop, and only for medium/high and only on *denials*. Recommendation: bind caller to a verifiable signal (peer UID / a server-issued token) rather than a self-asserted string, or document rate-limits as best-effort.

**M3 — The AI judge is a soft, prompt-injectable control.** The attacker-controlled `reason` (and `caller`/`scope`) are interpolated into the judge's user prompt (`judge.rs:177 user_prompt`). A sufficiently crafted reason can attempt prompt injection or simply be a plausible fabrication the model scores highly. `temperature:0` and structured JSON parsing help, and an unparseable reply degrades to `Unavailable` (medium fail-open, high fail-closed — verified at `gate.rs:122-150` and the daemon tests), but the judge should be treated as a probabilistic friction layer, not an authorization oracle. Additionally, **secret name, scope, caller, reason, and the vault/secret descriptions are transmitted to a third-party LLM (OpenRouter)** — no secret *value* leaves, but this metadata egress matters for high-security contexts and should be called out for adopters.

**M4 — The CLI agent path can trigger an interactive master-passphrase prompt that then opens the ungated window.** When no daemon is up and the vault is locked, `svault get` (agent path) falls through to `open_unlocked_or_prompt` (`cli/mod.rs:1144,1694`), which **prompts for the master passphrase**. If a human types it, the vault is cached for the full 6h `MAX_SESSION_SECS` and is thereafter readable via the *ungated* human path too. The MCP server correctly never prompts (`mcp/mod.rs:235` only reads an existing session). Recommendation: have the CLI agent path mirror MCP — never prompt; return "locked, a human must unlock."

### Low / Informational

- **L1 — `.session`/master-session files are key-equivalent.** Documented residual risk (`session.rs:1-8`). A stolen `0600` session opens that one vault for ≤6h; it no longer leaks the reusable passphrase. Acceptable and clearly communicated.
- **L2 — `meta.description` is plaintext (HMAC-signed) and is fed to the judge as "vault purpose."** Public-ish info, low impact, but it is the one policy-adjacent string readable at rest. Fine.
- **L3 — Windows ACL is best-effort.** `icacls /inheritance:r /grant:r` (`secfile.rs:49`) is fire-and-forget (`.output()` ignored); on failure the file keeps inherited ACLs. No daemon and no peer-UID equivalent on Windows. See deployment section.
- **L4 — AES-GCM random 96-bit nonces.** Safe at Svault's write volumes; worth a comment if a vault ever sees >2^32 re-encrypts.
- **L5 — Peer-UID fails closed correctly.** If `peer_uid` returns `None`, `puid != Some(me)` drops the connection (`daemon/mod.rs:600`). Good.

## Positive Security Properties Observed

- **Everything-encrypted-at-rest is real.** Policy/classification/callers/judge-assignment/seals all live inside GCM-authenticated `vault.enc` (`vault.rs:22-30`, `policy.rs:236-266`); `meta.yaml` carries only name/description/settings and is HMAC-signed with the DEK. The recon-resistance claim holds.
- **Unified master keyslot is OR-not-2FA and fails closed.** Passphrase / recovery-code / YubiKey each independently wrap the same MK (`master.rs`); a wrong slot fails on the GCM tag; recovery and rekey re-wrap MK without re-encrypting any vault (verified by `master.rs` tests).
- **6h re-auth cap is enforced on every path.** Timestamped sessions, back-dated → reads as locked and is securely removed (`session.rs:56-65`, test `session_expires_past_the_hard_cap`); daemon hard cap via `is_expired` ticker.
- **Daemon hardening is thorough:** `0600` socket bound under `umask(0077)` (no TOCTOU), peer-UID bond (`SO_PEERCRED`/`getpeereid`), connection ceiling + 30s read timeout + `ConnGuard`, mutex poison recovery, `sigaction`-based graceful shutdown that zeroizes keys, response buffers `zeroize()`d after write.
- **Generic denials with no leak** (`gate.rs:33 GENERIC_DENY`), full rationale only in the audit log stamped with source + peer UID.
- **Seal write-on-deny cannot be weaponized into a rewrite DoS:** `maybe_seal` short-circuits when already sealed or low-tier (`gate.rs:240`), so `vault.enc` is rewritten only on the rare seal transition.
- **MCP is a faithful thin frontend:** no privileged path, never prompts, locked = dead end, generic denials, capability descriptor advertises interface not criteria (`mcp/mod.rs`).
- **Supply chain:** `cargo audit --deny warnings` and `cargo clippy -D warnings` are CI gates (`lint.yml:38,49`); release attaches SHA-256 sums + SLSA build-provenance attestation (`release.yml:61-83`). `cargo audit` clean on the current lockfile.

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

Architecture is sound: one daemon per `.svault/`, thread-per-connection, `Arc<Mutex<HashMap<vault, Held>>>` with a deliberately tiny critical section (clone the 32-byte key + bump `last_used`, decrypt outside the lock). Keys are `Zeroizing<[u8;32]>`, never written to disk, zeroized on lock/evict/shutdown. The **client derives the key and only the 32-byte key crosses the socket** (`client.rs:47`, daemon validates it opens the vault before caching, `daemon/mod.rs:227`) — a wrong passphrase fails locally, never reaching the daemon. The peer-UID bond is implemented correctly per-platform and fails closed. The accept loop enforces the ceiling and recovers a poisoned mutex rather than aborting. Graceful shutdown via `sigaction(SA_RESTART)` flipping an atomic, then waking `accept()` — async-signal-safe.

Remaining daemon risks are the same-UID ones (M1): any same-UID process can send `Get` and read any unlocked secret with no gate, and can read the daemon's memory directly. The daemon does not widen the network attack surface (Unix socket, owner-only, UID-bonded). It is a clear net improvement over the file session.

## Infrastructure, Server & CI/CD Deployment Considerations

- **Improvements from the daemon:** on Unix servers/CI the preferred pattern is to unlock once (human or a controlled secret-injection step), let the daemon hold keys in memory under the 6h cap, and have agents use `svault get`/MCP so every access is gated-and-audited. No key touches disk.
- **Safe usage patterns (Unix today):** dedicate a UID per trust domain (the peer-UID bond is the *only* hard boundary — co-locating a hostile process under the same UID defeats every soft control); keep high-tier secrets human-only (judge off → high is human-only by `gate.rs:97`); rely on the audit log + seals for detection, not prevention; set a short idle timeout in CI.
- **Windows / non-Unix:** no daemon (file-session fallback), no peer-UID bond, best-effort `icacls` only. Treat Windows as the weakest target; avoid high-value secrets there, or keep them human-only.
- **High-value secrets:** prefer YubiKey-gated master unlock, keep them `high` tier with the judge enabled (fail-closed), and accept that on a shared-UID host the real protection is the audit trail + seal escalation, not denial.

## Recommendations for Corporate Adoption

Prioritized:
1. **(M2)** Stop keying rate/burst limits on the self-asserted caller; bind to peer UID or a server-issued token, or relabel them best-effort. *Should-fix before 1.0.*
2. **(M4)** Make the CLI agent path non-prompting like MCP, so an agent can't induce a master prompt that opens the 6h ungated window. *Should-fix before 1.0.*
3. **(M1/M3)** Keep the same-UID/advisory-gate and judge-is-soft framing front-and-center in `docs/security.md` and the MCP/policy docs; document the metadata egress to OpenRouter. *Doc-fix.*
4. **(L3)** Verify the `icacls` result and warn on failure; document Windows as reduced-assurance.

**Is the enforced gate + AI judge sound enough for a 1.0.0 "stable" label?** The *cryptography, at-rest model, keyslot design, session/6h-cap, and daemon* are 1.0-quality today. The *enforcement gate* is sound **as scoped** — cooperative agents + audit — and is honestly documented as such. I would support a 1.0.0 that ships with M2 and M4 fixed and the advisory-scope language made unmissable; I would not endorse marketing the judge/seal as a boundary against a hostile same-UID actor (the code does not, and the docs should keep not doing so).

## Overall Risk Assessment by Context

| Context | Rating | Notes |
|---|---|---|
| Personal use (Unix + daemon) | **Low** | Strong crypto, 6h cap, audited gate; same-UID caveat irrelevant solo |
| Small team (Unix, dedicated UIDs) | **Low–Moderate** | Good if each trust domain gets its own UID; gate is advisory within a UID |
| Corporate CI/CD with AI agents (Unix) | **Moderate** | Use daemon + gate + audit; fix M2/M4; high-tier human-only; per-UID isolation is the real boundary |
| High-security production infra | **Moderate–High** | Judge is soft + metadata egress; same-UID isn't a sandbox; prefer YubiKey + human-only high tier, treat seals as detection |
| Mixed / Windows environments | **High (relative)** | No daemon, no peer-UID, best-effort ACL; avoid high-value secrets |

## References

- `src/daemon/mod.rs` (peer-UID bond `:545`, socket bind/umask `:747`, `GetGated` `:315`, human `Get` `:273`), `src/daemon/client.rs`
- `src/core/policy.rs` (windows/required-callers `:419-433`, seal constants `:34`, rate/burst `:486`), `src/core/gate.rs` (`authorize` `:53`, `maybe_seal` `:239`, `GENERIC_DENY` `:33`)
- `src/core/judge.rs` (prompt `:165-199`, fail modes via `gate.rs:122-150`), `src/core/master.rs`, `src/core/session.rs` (`MAX_SESSION_SECS` `:18`), `src/core/crypto.rs`, `src/core/vault.rs` (`:544` no-leak test), `src/core/meta.rs` (HMAC `:168-217`), `src/core/secfile.rs`
- `src/mcp/mod.rs`, `src/cli/mod.rs` (`cmd_get` `:1097`, `cmd_secret get` `:982`, `cmd_approve` `:1445`, `open_unlocked_or_prompt` `:1694`)
- `.github/workflows/lint.yml` (clippy/audit gates), `.github/workflows/release.yml` (SHA-256 + SLSA provenance)
- Prior findings registers: `docs/security-review/findings/{0.5.0,0.6.0,0.7.0,0.9.0}.md`
