# Svault 0.9.0 — Findings Register

> **Status: maintainer pre-review register.** 0.9.0 is the release that turns the
> policy engine from **advisory** into **enforced**. This register records what
> 0.9.0 closed from the carry-forward backlog, the one residual (N-1), and the
> **new attack surface** the AI judge introduces — which the pending independent
> 0.9.0 review (the explicit 1.0.0 gate) must scrutinise. No independent 0.9.0
> review has been run yet; `reviews/` holds 0.5.0 and 0.7.0. The
> [PROMPT.md](../PROMPT.md) is already updated to target 0.9.0.

- **Version:** 0.9.0
- **Date:** 2026-05-30
- **Maintainer:** Nima Karimi
- **Independent review:** **pending** — gates 1.0.0 (see "Remaining before 1.0.0").
- **Tooling pass:** `cargo test` (99 pass, 1 ignored stress benchmark), `cargo clippy --all-targets --all-features -- -D warnings` (clean), `cargo audit` (clean across 298 deps — `ureq`/`rustls` added), `cargo fmt --check` (clean) — green on the 4-OS CI (Ubuntu/macOS/Windows/Fedora).

## Headline: the advisory-policy gap is closed

All three 0.7.0 reviews unanimously flagged **the same blocker** for a 1.0.0
"enforced" label: the policy engine was advisory — the daemon `Get` path ran no
policy and no audit, so a same-UID process bypassed the whole layer by speaking
the socket directly, calling `svault secret get`, or editing the unsigned policy
file. 0.9.0 moves enforcement **into the daemon** and signs the classification:

- The agent path is now a `GetGated` request. The daemon runs `policy::evaluate`
  + the tier/AI-judge gate (`gate::authorize`), **audits the decision stamped with
  the unforgeable peer UID**, and only then returns a value. There is no unguarded
  read path (`daemon.rs` `Request::GetGated`).
- Secret classification (scope/tier/`require_reason`/`description`) moved to the
  **HMAC-signed `meta.yaml`**, loaded **verified** (`Vault::open_with_key` →
  `VaultMeta::load_verified`) before gating — a tier can't be downgraded without
  the vault key.
- `svault.policy.yaml` now holds **only caller definitions** (non-secret,
  committable); discovery is **anchored to the project root** and a
  present-but-unparseable file **fails closed**.

## Carry-forward — the deferred 0.9.0 policy thread

These five were deferred from the 0.7.0 register's "0.8.0 scope → deferred to
0.9.0" list. Status verified against the 0.9.0 source.

| # | Finding (short) | Status in 0.9.0 | Evidence |
|---|-----------------|-----------------|----------|
| 2 / N-5 | Policy engine advisory; daemon `Get` unaudited | **Fixed** | `GetGated` → `gate::authorize` → `audit_gated(..., peer_uid)`; no unguarded path |
| 22 | Gate reads `meta.yaml` unverified | **Fixed** | `open_with_key` → `VaultMeta::load_verified` (HMAC) before the gate (`daemon.rs`) |
| N-2 | Policy parse failure fails *open* | **Fixed** | `policy::load()` → `PolicyLoad::{Absent, Loaded, Error}`; `Error` → deny + audit |
| 5 | Policy unsigned + unbounded upward search | **Core fixed** | Sensitive classification now in **signed `meta.yaml`**; policy file holds only callers (intentionally unsigned/committable); discovery anchored to the dir holding `.svault/` |
| N-1 | Caller identity self-asserted (`--caller`/`$SVAULT_CALLER`) | **Partial** | Audit is now stamped with the **unforgeable peer UID** (trustworthy attribution). The `--caller` string used for *authorization* (scope lookup) is **still self-asserted** — no per-agent token / OS binding. See residual below. |

### N-1 residual (the one open item from the deferred thread)

Authorization still keys off a self-asserted `--caller` string, so a same-UID
process can present itself as any caller for scope purposes. What 0.9.0 *did* fix
is **attribution**: every decision is recorded with the connecting process's real
peer UID, which it cannot forge, so the audit trail is honest even when the caller
label is not. Under the project's documented **same-UID trust model** this is
defensible — that process can already read the daemon's memory directly, so caller
spoofing is not a new boundary break. The open question for the independent review:
**is peer-UID-stamped audit sufficient for a 1.0.0 "enforced" claim, or is a
per-agent token / OS-bound caller identity required?** Tracked, not a functional
blocker.

## New attack surface introduced in 0.9.0 (for the independent review)

The AI judge is a new capability prior reviews never saw. Flagged here so the next
review weighs it explicitly rather than rediscovering it.

| # | Surface | Sev (maintainer est.) | Notes / current mitigation |
|---|---------|----------------------|----------------------------|
| J-1 | **Prompt injection via the `reason` field** | Med-High | The attacker controls the `reason` string sent to the model and could try to coax an "allow" (e.g. "ignore previous instructions"). Current mitigation: a strict system prompt demanding compact JSON only, `temperature: 0`, the decision being matched against secret/vault **descriptions**, and the gate honouring **`decision: deny` regardless of score**. **Not yet adversarially tested** — the review should probe it. |
| J-2 | **Third-party data egress to OpenRouter** | Med | The judge sends secret **name, scope, tier, caller, reason, recent-activity summary, and any vault/secret descriptions** (never the value) to the configured model. Off until a key is set; descriptions are user-authored. Factor into data-handling posture; docs warn to keep descriptions free of sensitive data. |
| J-3 | **OpenRouter API-key handling** | Low-Med | Key comes only from `$SVAULT_OPENROUTER_KEY` or a `0600` file under `~/.config/svault/` (never config, never the repo; `.gitignore` blocks `*.key`). `set-key` writes `0600`, refuses loose perms on read, never echoes the key. Review: confirm no logging/leak path and that `Zeroizing` covers the key in memory. |
| J-4 | **Judge fail modes** | Info/Med | Tier-dependent by design: low skipped (unless `require_reason`), medium **fail-open** + `judge-unavailable` audit flag, high **fail-closed**. With the judge off, behaviour falls back to the pre-0.9.0 rule (high = human-only). Review: confirm the high path can't be forced open by an induced "unavailable", and that the model/threshold config can't be downgraded by an unprivileged edit. |

## Accepted / backlog — unchanged, **not** 1.0.0 blockers

Carried forward from the 0.7.0 register with the same dispositions:

- **N-7** — Windows atomic owner-only DACL (creation-time TOCTOU; Unix daemon is the primary path).
- **N-8 / #15** — tamper-evident audit sink (HMAC/hash-chain or append-only remote); the log holds no secret values.
- **N-11** — daemon `Unlock` rate-limit (each attempt is a full Argon2id+AES verify; peer is same-UID bonded).
- **N-12** — tunable Argon2id params (needs an on-disk format version field).
- **N-13** — dictionary/phrase-aware entropy (the length×log2(pool) floor under-credits by design).
- **N-6 (part)** — `Vault::load_secrets` builds a non-`Zeroizing` `HashMap` (freed-not-wiped); accepted under the at-rest/cooperative model.
- Older accepted: #7 (per-write nonce), #18 (recovery-code checksum), #19 (full-DB decrypt on open), #21 (no GCM AAD).

## Remaining before 1.0.0

1. **Independent security review of the 0.9.0 enforced engine** — the explicit gate. Run `PROMPT.md` through one or more models that did not write the code; record under `reviews/0.9.0-<reviewer>.md` and consolidate the dispositions back into this register.
2. **Decide N-1** — accept peer-UID-stamped audit as sufficient (and document it as the boundary), or implement a per-agent token / OS-bound caller identity.
3. **Adversarially test the judge** — J-1 prompt injection in particular; tighten the system prompt / add output validation if it bends.
4. **Distribution channels** — install script, Homebrew tap, cargo-binstall, Docker (see [roadmap](../roadmap.md)).

## Sign-off

The five deferred policy-thread items are closed in code (N-1 partial — attribution
fixed, authorization still self-asserted, documented above). The judge's new surface
(J-1…J-4) and the N-1 decision are routed to the pending independent review, which
gates the 1.0.0 "stable / enforced" label.

— Nima Karimi, 2026-05-30
