# Plan A — v0.5.0 Polish + Harden — Security + Cleanup (Rust + Docs)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. TDD discipline throughout: write a failing test, run to confirm fail, implement, run to confirm pass, then commit.

**Goal:** Land the four Rust + docs items in the v0.5.0 polish-harden bundle: fix the broken `relicario-server` pre-receive hook (S1, the security anchor), harden the backup-restore tar unpack against path-traversal/symlink/tar-bomb attacks (S2), audit and document `RELICARIO_*` env vars while moving the dev-only one under `cfg(debug_assertions)` (S3), and prune merged local feature branches with per-branch user confirmation (C1). Plan A's scope is bounded — items B1, B2, P1-P4 belong to Plan B (Extension UX) and are explicitly out of scope here.

**Architecture:** S1 rewrites `verify_commit` to load `devices.json` and `revoked.json` at the commit, build a temporary `gpg.ssh.allowedSignersFile`, run `git verify-commit --raw` with `GIT_CONFIG_*` env to point at that file, parse the signing-key SHA256 fingerprint out of the `Good "git" signature for relicario with ED25519 key SHA256:...` line on stderr, and accept only when the fingerprint matches a registered, non-revoked device (using committer-date for the historical-commit revocation window). S2 swaps `tar::Archive::unpack` for an explicit per-entry loop with `..` / absolute / drive-letter rejection, symlink and hardlink rejection, a 100×-or-1-GiB size cap, and a final canonicalised `starts_with(target)` check. S3 documents every `RELICARIO_*` env var in `docs/SECURITY.md` and the relevant clap doc-comments and `cfg(debug_assertions)`-gates `RELICARIO_NO_GROUPS_CACHE`. C1 walks the five known-merged feature branches one-by-one, confirms each is in `git branch --merged main` output, and prompts the user before each `git branch -D`.

**Tech Stack:** Rust (`relicario-core`, `relicario-cli`, `relicario-server`), `ssh-key` crate (already a `relicario-core` dependency) for fingerprint computation, `tar` crate (already a `relicario-cli` dependency) iterated by hand, `tempfile` (already a `relicario-cli` dev-dependency, mirror to `relicario-server`), `assert_cmd` (already in `relicario-cli`'s dev-dependencies, mirror to `relicario-server`).

---

## File Structure

| File | Change | Purpose |
|------|--------|---------|
| `crates/relicario-core/src/device.rs` | Modify | Add `pub fn fingerprint(public_key_openssh: &str) -> Result<String>` returning `SHA256:<base64>` (S1 needs this; keep in core so server + tests share it) |
| `crates/relicario-core/src/lib.rs` | Modify | Re-export `device::fingerprint` |
| `crates/relicario-server/Cargo.toml` | Modify | Add `tempfile`, `assert_cmd`, `predicates` to `[dev-dependencies]` |
| `crates/relicario-server/src/main.rs` | Rewrite `verify_commit` (S1) | Allowed-signers temp file, fingerprint extraction, devices/revoked check |
| `crates/relicario-server/tests/verify_commit.rs` | Create | 4 acceptance integration tests for S1 |
| `crates/relicario-cli/src/main.rs` | Modify (S2) | Replace `archive.unpack(...)` at line 1722 with `safe_unpack_git_archive` call; add the helper above `cmd_backup_restore` |
| `crates/relicario-cli/src/helpers.rs` | Modify (S3) | `cfg(debug_assertions)`-gate the `RELICARIO_NO_GROUPS_CACHE` check; document |
| `crates/relicario-cli/src/main.rs` | Modify (S3) | Update the doc-comment on `cmd_groups`/CLI long-help mentioning the env var to note it's debug-only |
| `crates/relicario-cli/tests/backup.rs` (or new `crates/relicario-cli/tests/restore_hardening.rs`) | Modify/Create | 3 S2 acceptance tests + happy-path stays green |
| `docs/SECURITY.md` | Modify (S3) | Add a "Configuration env vars" section enumerating every `RELICARIO_*` var with purpose + trust assumption |
| _local git branches_ | Delete (C1) | Five merged feature branches, interactive |

---

## Task 1: S1 — Add `device::fingerprint` helper to relicario-core

**Files:**
- Modify: `crates/relicario-core/src/device.rs`
- Modify: `crates/relicario-core/src/lib.rs`

The `relicario-server` `verify_commit` rewrite needs to compute SHA256 fingerprints from OpenSSH public keys (so it can match `git verify-commit --raw` stderr output against `devices.json` / `revoked.json` entries). The `ssh-key` crate already exposes this via `PublicKey::fingerprint(HashAlg::Sha256)`. We add it to core so the server crate (and unit tests) share the same implementation.

- [ ] **Step 1: Write failing unit test for `fingerprint`**

Add to `crates/relicario-core/src/device.rs` `tests` module:

```rust
#[test]
fn fingerprint_matches_ssh_keygen_format() {
    // Generate a key, compute fingerprint, and confirm format.
    let (_, public) = generate_keypair().unwrap();
    let fp = fingerprint(&public).unwrap();
    // Format from ssh-keygen -l: "SHA256:<43 chars of url-safe base64 without padding>"
    assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
    let body = fp.strip_prefix("SHA256:").unwrap();
    assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
    assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
}

#[test]
fn fingerprint_is_deterministic() {
    let (_, public) = generate_keypair().unwrap();
    assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
}

#[test]
fn fingerprint_differs_per_key() {
    let (_, p1) = generate_keypair().unwrap();
    let (_, p2) = generate_keypair().unwrap();
    assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
}
```

- [ ] **Step 2: Run test to confirm fail**

Run: `cargo test -p relicario-core device::tests::fingerprint`
Expected: compilation error — `fingerprint` is not defined.

- [ ] **Step 3: Implement `fingerprint`**

Add to `crates/relicario-core/src/device.rs`:

```rust
/// Compute the OpenSSH SHA-256 fingerprint of a public key, formatted exactly as
/// `ssh-keygen -lf` and as `git verify-commit --raw` reports it: `SHA256:<base64>`
/// (standard base64 without padding).
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
    use ssh_key::HashAlg;
    let public = PublicKey::from_openssh(public_key_openssh)
        .map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
    Ok(public.fingerprint(HashAlg::Sha256).to_string())
}
```

- [ ] **Step 4: Re-export from lib.rs**

In `crates/relicario-core/src/lib.rs`, find the existing `pub use device::...` line and add `fingerprint`:

```rust
pub use device::{fingerprint, generate_keypair, sign, verify, DeviceEntry, RevokedEntry};
```

- [ ] **Step 5: Run tests**

Run: `cargo test -p relicario-core device`
Expected: all device tests pass including the three new fingerprint tests.

- [ ] **Step 6: Cross-check fingerprint format against ssh-keygen**

Sanity-check that the format matches what `git verify-commit --raw` actually emits (we captured this during planning — see `crates/relicario-server/tests/verify_commit.rs` Step 2 below where it gets exercised end-to-end). If the `ssh-key` crate's `Fingerprint::Display` emits something different from the `SHA256:NX7cg...` form, this is the place to find out. Format note for the engineer: ssh-keygen omits trailing `=` padding from base64, exactly 43 chars for SHA-256.

- [ ] **Step 7: Commit**

```bash
git add crates/relicario-core/src/device.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add device::fingerprint helper for SSH SHA256 fingerprints

Wraps `ssh-key`'s `PublicKey::fingerprint(HashAlg::Sha256)`. Output
format matches `ssh-keygen -lf` and `git verify-commit --raw` stderr
("SHA256:<43-char base64>"). Used by the upcoming relicario-server
verify-commit rewrite (audit S1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: S1 — Add dev-dependencies to relicario-server

**Files:**
- Modify: `crates/relicario-server/Cargo.toml`

The S1 acceptance tests need `assert_cmd` to spawn the binary and `tempfile` to build throwaway repos. Both are already used in `relicario-cli` — mirror the versions to keep workspace dependency drift minimal.

- [ ] **Step 1: Confirm versions used by `relicario-cli`**

Read `crates/relicario-cli/Cargo.toml` `[dev-dependencies]`. As of writing: `assert_cmd = "2"`, `predicates = "3"`, `tempfile = "3"`. If any have changed, use the values currently in that file.

- [ ] **Step 2: Add to relicario-server Cargo.toml**

Edit `crates/relicario-server/Cargo.toml`. Append (or merge if section exists):

```toml
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
```

- [ ] **Step 3: Verify it builds**

Run: `cargo build -p relicario-server --tests`
Expected: succeeds (no tests yet, just confirms manifest is valid).

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-server/Cargo.toml
git commit -m "$(cat <<'EOF'
chore(server): add assert_cmd/predicates/tempfile dev-deps

Needed for the upcoming verify-commit acceptance suite (audit S1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: S1 — Write the four failing acceptance tests

**Files:**
- Create: `crates/relicario-server/tests/verify_commit.rs`

These tests drive the implementation. They build a real on-disk git repo, generate two ed25519 keypairs, write `.relicario/devices.json` and `.relicario/revoked.json`, sign commits with `git -c gpg.format=ssh ...`, then invoke the `relicario-server verify-commit <sha>` binary via `assert_cmd` and assert exit code + stderr.

**Reference output formats** (captured from a real `git -c gpg.format=ssh ...` repo during planning):

- Signed by allowed key, exit 0:  
  `Good "git" signature for relicario with ED25519 key SHA256:NX7cgViq2JvCRDYWVun/kiGYDbl5yyNKApg/L5e/ixU`
- Signed by non-allowed key (still has GOODSIG line!), exit 1:  
  `Good "git" signature with ED25519 key SHA256:BMyEafi4tdXsSdaOwCqdLXq25Xe3I/LzZWG63WCvfBE`  
  `No principal matched.`
- Unsigned commit: empty stderr, exit non-zero from `git verify-commit`.

The current code's `stderr.contains("Good signature")` check is doubly wrong: SSH signatures emit `Good "git" signature ...`, AND that line shows up even for unknown keys. We need fingerprint matching against `devices.json`.

- [ ] **Step 1: Create the test file with helpers**

Write `crates/relicario-server/tests/verify_commit.rs`:

```rust
//! Acceptance tests for `relicario-server verify-commit`.
//!
//! These build real on-disk git repos with SSH-signed commits and invoke the
//! binary via `assert_cmd`. They cover the four scenarios from the audit S1
//! acceptance criteria:
//!
//! 1. Revoked key, commit dated AFTER `revoked_at` -> exit 1
//! 2. Unregistered key -> exit 1
//! 3. Registered, non-revoked key -> exit 0
//! 4. Revoked key, commit dated BEFORE `revoked_at` (historical) -> exit 0

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use assert_cmd::Command as AssertCommand;
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
use tempfile::TempDir;

/// Write a private OpenSSH key + matching .pub file into `dir`, returning
/// (private_path, public_path, public_openssh_string).
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
    let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
    let priv_path = dir.join(format!("{name}.key"));
    let pub_path = dir.join(format!("{name}.pub"));
    fs::write(&priv_path, priv_pem.as_str()).unwrap();
    fs::write(&pub_path, &pub_line).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
    }
    (priv_path, pub_path, pub_line)
}

/// Run `git` in `repo` with the given args; panic on non-zero exit.
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
    let mut cmd = Command::new("git");
    cmd.current_dir(repo).args(args);
    for (k, v) in extra_env {
        cmd.env(k, v);
    }
    let status = cmd.status().expect("spawn git");
    assert!(status.success(), "git {args:?} failed");
}

/// Initialise a git repo with an empty initial commit (unsigned, bootstrap).
fn init_repo(repo: &Path) {
    git(repo, &["init", "-q", "-b", "main"], &[]);
    git(repo, &["config", "user.email", "test@test"], &[]);
    git(repo, &["config", "user.name", "test"], &[]);
    git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
}

/// Sign a commit with `signing_key` against `allowed_signers` and a fixed
/// committer date (unix timestamp). Returns the new commit SHA.
fn sign_commit(
    repo: &Path,
    signing_key: &Path,
    allowed_signers: &Path,
    committer_unix: i64,
    msg: &str,
    file_path: &str,
    file_content: &str,
) -> String {
    fs::write(repo.join(file_path), file_content).unwrap();
    git(repo, &["add", file_path], &[]);
    let date = format!("@{committer_unix} +0000");
    git(
        repo,
        &[
            "-c", "gpg.format=ssh",
            "-c", &format!("user.signingkey={}", signing_key.display()),
            "-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
            "commit", "-S", "-q", "-m", msg,
        ],
        &[
            ("GIT_AUTHOR_DATE", &date),
            ("GIT_COMMITTER_DATE", &date),
        ],
    );
    let out = Command::new("git")
        .current_dir(repo)
        .args(["rev-parse", "HEAD"])
        .output()
        .unwrap();
    String::from_utf8(out.stdout).unwrap().trim().to_string()
}

/// Write `.relicario/devices.json` and `.relicario/revoked.json`, commit, return SHA.
/// The commit itself is unsigned/bootstrap so we don't recurse into verification.
fn write_device_files(
    repo: &Path,
    devices: &[DeviceEntry],
    revoked: &[RevokedEntry],
) {
    let dir = repo.join(".relicario");
    fs::create_dir_all(&dir).unwrap();
    fs::write(
        dir.join("devices.json"),
        serde_json::to_string_pretty(devices).unwrap(),
    ).unwrap();
    fs::write(
        dir.join("revoked.json"),
        serde_json::to_string_pretty(revoked).unwrap(),
    ).unwrap();
    git(repo, &["add", ".relicario"], &[]);
    git(repo, &["commit", "-q", "-m", "device files"], &[]);
}

#[test]
fn registered_non_revoked_key_accepted() {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    init_repo(repo);

    let (priv_a, _pub_path_a, pub_a) = write_keypair(repo, "alice");
    write_device_files(
        repo,
        &[DeviceEntry {
            name: "alice".into(),
            public_key: pub_a.clone(),
            added_at: 1_700_000_000,
            added_by: "bootstrap".into(),
        }],
        &[],
    );

    // Allowed-signers file used only by the test fixture's git invocation
    // (the binary builds its own at verify time).
    let allowed = repo.join("test_allowed_signers");
    fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();

    let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-commit", &sha])
        .assert()
        .success();
}

#[test]
fn unregistered_key_rejected() {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    init_repo(repo);

    let (_priv_a, _, pub_a) = write_keypair(repo, "alice");
    let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");

    // Only Alice is registered.
    write_device_files(
        repo,
        &[DeviceEntry {
            name: "alice".into(),
            public_key: pub_a.clone(),
            added_at: 1_700_000_000,
            added_by: "bootstrap".into(),
        }],
        &[],
    );

    // Evil signs against an allowed-signers file containing both keys (so
    // git verify-commit emits a "Good" line); the binary should still reject
    // because evil isn't in devices.json.
    let allowed = repo.join("test_allowed_signers");
    fs::write(
        &allowed,
        format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
    ).unwrap();

    let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-commit", &sha])
        .assert()
        .failure()
        .stderr(predicates::str::contains("unregistered"));
}

#[test]
fn revoked_key_after_revoked_at_rejected() {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    init_repo(repo);

    let (priv_a, _, pub_a) = write_keypair(repo, "alice");

    // Alice was registered, then revoked at t=1_705_000_000.
    write_device_files(
        repo,
        &[],
        &[RevokedEntry {
            name: "alice".into(),
            public_key: pub_a.clone(),
            revoked_at: 1_705_000_000,
            revoked_by: "admin".into(),
        }],
    );

    let allowed = repo.join("test_allowed_signers");
    fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();

    // Commit dated AFTER revocation.
    let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-commit", &sha])
        .assert()
        .failure()
        .stderr(predicates::str::contains("revoked"));
}

#[test]
fn revoked_key_before_revoked_at_accepted_historical() {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    init_repo(repo);

    let (priv_a, _, pub_a) = write_keypair(repo, "alice");

    // Devices.json (current state) lists Alice as revoked.
    write_device_files(
        repo,
        &[],
        &[RevokedEntry {
            name: "alice".into(),
            public_key: pub_a.clone(),
            revoked_at: 1_705_000_000,
            revoked_by: "admin".into(),
        }],
    );

    let allowed = repo.join("test_allowed_signers");
    fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();

    // Commit dated BEFORE revocation -- historical-commit case.
    let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-commit", &sha])
        .assert()
        .success();
}
```

- [ ] **Step 2: Run the suite to confirm all four fail**

Run: `cargo test -p relicario-server --test verify_commit`
Expected: all four tests fail. The current `verify_commit` accepts everything that has a "Good signature" or "GOODSIG" string — `unregistered_key_rejected` and `revoked_key_after_revoked_at_rejected` will pass on a passing-everything implementation (which is the bug), but they assert specific stderr substrings that the current code never emits, so they will fail. `registered_non_revoked_key_accepted` and `revoked_key_before_revoked_at_accepted_historical` may or may not pass coincidentally — both should be checked under the *new* implementation.

NOTE: do not commit yet — these are paired with the implementation in Task 4.

---

## Task 4: S1 — Implement `verify_commit`

**Files:**
- Modify: `crates/relicario-server/src/main.rs`

Replace the body of `verify_commit` with the real check. The flow:

1. Load `devices.json` at the commit (existing logic).
2. Bootstrap: empty/missing -> accept.
3. Load `revoked.json` at the commit (existing logic, unwrap_or_default).
4. Build a temp file `<tmp>/allowed_signers` containing one line per device entry: `relicario <pub_key_openssh>` (newline-terminated).
5. Spawn `git verify-commit --raw <commit>` with env `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile`, `GIT_CONFIG_VALUE_0=<tmp>/allowed_signers`. Capture stderr.
6. If exit code is non-zero -> reject ("git verify-commit failed: <stderr>").
7. Parse the SHA256 fingerprint out of stderr. The line we want is `Good "git" signature for relicario with ED25519 key SHA256:<base64>`. Match a regex like `key (SHA256:[A-Za-z0-9+/]+)` — first capture group.
8. Build a fingerprint-to-name map from `devices` via `relicario_core::device::fingerprint` (skip entries that fail to parse, with a warning).
9. If the parsed fingerprint isn't in the map -> reject ("signed by unregistered device <fp>").
10. Get committer date: `git show -s --format=%ct <commit>`, parse as `i64`.
11. Build a fingerprint -> `revoked_at` map from `revoked` via `fingerprint`. If the parsed fingerprint is in this map AND `committer_ts >= revoked_at` -> reject ("signed by revoked device '<name>'").
12. Else -> accept.

The `let _ = &revoked;` dead code goes away. The `devices.contains` check is the new gate.

- [ ] **Step 1: Add `tempfile` to runtime deps (not just dev)**

`tempfile` is already a dev-dependency (Task 2) but the binary needs it at runtime. Edit `crates/relicario-server/Cargo.toml` `[dependencies]`:

```toml
tempfile = "3"
regex = "1"
```

(`regex` is needed for fingerprint extraction; alternative is hand-rolled string slicing — regex is clearer.)

- [ ] **Step 2: Replace `verify_commit` body**

Edit `crates/relicario-server/src/main.rs`. Replace the entire `verify_commit` function (lines 36-81) with:

```rust
fn verify_commit(commit: &str) -> Result<()> {
    // 1. Load devices.json at this commit (bootstrap-tolerant).
    let devices_json = match git_show(commit, ".relicario/devices.json") {
        Ok(json) => json,
        Err(_) => {
            eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
            return Ok(());
        }
    };
    let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
        .context("parse devices.json")?;

    // 2. Empty devices.json => bootstrap mode.
    if devices.is_empty() {
        eprintln!("OK: commit {commit} (bootstrap - empty devices.json)");
        return Ok(());
    }

    // 3. Load revoked.json (may not exist).
    let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();

    // 4. Build temp allowed-signers file (one entry per registered device).
    let tmp = tempfile::tempdir().context("create tempdir")?;
    let allowed_path = tmp.path().join("allowed_signers");
    let mut allowed_body = String::new();
    for d in &devices {
        // Format per ssh-keygen(1): principal[,principal...] [options] keytype base64key
        // We use "relicario" as the single principal (matches what the CLI configures).
        allowed_body.push_str("relicario ");
        allowed_body.push_str(d.public_key.trim());
        allowed_body.push('\n');
    }
    std::fs::write(&allowed_path, allowed_body).context("write allowed_signers")?;

    // 5. Run git verify-commit --raw with our allowed-signers (NOT mutating
    //    global git config -- use GIT_CONFIG_COUNT/KEY/VALUE override).
    let output = Command::new("git")
        .args(["verify-commit", "--raw", commit])
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
        .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
        .output()
        .context("git verify-commit")?;

    let stderr = String::from_utf8_lossy(&output.stderr);

    // 6. Non-zero exit means: unsigned, bad signature, or signed by a key
    //    not in our allowed-signers file (which is exactly the registered
    //    device set). All three are rejection cases.
    if !output.status.success() {
        eprintln!("REJECT: commit {commit} — git verify-commit failed: {}", stderr.trim());
        std::process::exit(1);
    }

    // 7. Parse the SHA-256 fingerprint of the signing key from stderr.
    //    SSH signature line format:
    //      Good "git" signature for relicario with ED25519 key SHA256:<base64>
    //    (capture the SHA256:... token; it's exactly what device::fingerprint emits.)
    let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)")
        .expect("static regex");
    let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
        Some(m) => m.as_str().to_string(),
        None => {
            eprintln!(
                "REJECT: commit {commit} — could not parse signing key fingerprint from: {}",
                stderr.trim()
            );
            std::process::exit(1);
        }
    };

    // 8. Build device fingerprint maps.
    let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
        std::collections::HashMap::new();
    for d in &devices {
        match relicario_core::device::fingerprint(&d.public_key) {
            Ok(fp) => { device_by_fp.insert(fp, d); }
            Err(e) => eprintln!(
                "warning: skipping unparseable device entry '{}': {e}",
                d.name
            ),
        }
    }

    // 9. Reject if signing key isn't a registered device.
    if !device_by_fp.contains_key(&signing_fp) {
        eprintln!(
            "REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
        );
        std::process::exit(1);
    }

    // 10. Get committer date (NOT author date — committer date is when the
    //     signature was applied; author date can be backdated by an attacker).
    let ct_out = Command::new("git")
        .args(["show", "-s", "--format=%ct", commit])
        .output()
        .context("git show committer date")?;
    if !ct_out.status.success() {
        anyhow::bail!("git show committer date failed for {commit}");
    }
    let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
        .trim()
        .parse()
        .context("parse committer timestamp")?;

    // 11. Reject if signing key is revoked AND commit is at-or-after revocation.
    //     Historical commits (committer_ts < revoked_at) survive the revoke.
    for r in &revoked {
        let r_fp = match relicario_core::device::fingerprint(&r.public_key) {
            Ok(fp) => fp,
            Err(_) => continue,
        };
        if r_fp == signing_fp && committer_ts >= r.revoked_at {
            eprintln!(
                "REJECT: commit {commit} — signed by revoked device '{}' \
                 (committer ts {committer_ts} >= revoked_at {})",
                r.name, r.revoked_at
            );
            std::process::exit(1);
        }
    }

    eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
    Ok(())
}
```

- [ ] **Step 3: Run the four acceptance tests**

Run: `cargo test -p relicario-server --test verify_commit`
Expected: all four pass.

- [ ] **Step 4: Run the rest of the workspace tests to confirm no regression**

Run: `cargo test`
Expected: all tests still pass.

- [ ] **Step 5: Manual sanity check (optional but good)**

Build the binary and inspect a known-bad case:

```bash
cargo build -p relicario-server
# (binary at target/debug/relicario-server)
```

Then either run the test fixture by hand or skip — the four tests cover the four spec scenarios.

- [ ] **Step 6: Commit (S1 implementation + tests together)**

```bash
git add crates/relicario-server/Cargo.toml \
        crates/relicario-server/src/main.rs \
        crates/relicario-server/tests/verify_commit.rs
git commit -m "$(cat <<'EOF'
fix(server): real signature verification in pre-receive hook (audit S1)

`verify_commit` previously loaded devices.json/revoked.json and threw
both away, accepting any commit whose stderr contained "GOODSIG" or
"Good signature". This left device registration and revocation as no-ops:
unregistered keys could push, and revoked keys kept working forever.

The fix:

- Build a temp `gpg.ssh.allowedSignersFile` from devices.json at the
  commit, passed via GIT_CONFIG_COUNT/KEY/VALUE so we never mutate
  global git config.
- Run `git verify-commit --raw` and require zero exit.
- Parse `SHA256:<base64>` fingerprint out of the SSH "Good" line.
- Reject if fingerprint isn't a registered device.
- Reject if fingerprint is in revoked.json AND committer date >=
  revoked_at. Historical commits (committer_ts < revoked_at) still
  verify — old history survives revocation.

Acceptance: 4 integration tests covering the matrix
(registered/unregistered x revoked/non-revoked, with revoked split into
post- and pre-revocation cases).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: S2 — Write failing tests for path-traversal hardening

**Files:**
- Create: `crates/relicario-cli/tests/restore_hardening.rs`

The current `cmd_backup_restore` calls `tar::Archive::unpack(target.join(".git"))` blindly. We need three failing tests that craft malicious tar payloads inside a `.relbak`, then assert restore exits with an error and never writes outside the target directory.

The simplest path: factor the tar-extraction into a `safe_unpack_git_archive(tar_bytes: &[u8], dest: &Path, max_size: u64) -> Result<()>` function and unit-test that directly. Plus one CLI integration test for the happy path.

- [ ] **Step 1: Decide test layer**

Two options:
- (A) Pure-Rust unit test of `safe_unpack_git_archive`, hand-crafting tar entries with the `tar::Builder` API.
- (B) End-to-end test: build a malicious `.relbak`, run the CLI restore command, assert error.

Pick **A** — fewer moving parts, easier to control payload exactly. The happy-path E2E case is already covered by existing backup roundtrip tests.

- [ ] **Step 2: Create the test file**

Write `crates/relicario-cli/tests/restore_hardening.rs`:

```rust
//! Acceptance tests for tar-archive hardening on backup restore (audit S2).
//!
//! Three malicious-input scenarios that the unpacker must refuse:
//!   1. Path traversal: an entry whose path contains `..`
//!   2. Symlink: a symlink entry pointing outside the destination
//!   3. Tar bomb: total uncompressed size exceeds the cap

use std::io::Write;
use std::path::PathBuf;

use tar::{Builder, Header};
use tempfile::TempDir;

// The function under test lives in the CLI binary crate. Easiest way to
// call it from an integration test: re-export via a shim. See
// `crates/relicario-cli/src/main.rs` -- we add `pub` on the helper and
// expose it through a small `pub mod restore_hardening` module wired
// into `lib.rs`. If `relicario-cli` has no `lib.rs` yet, create one
// re-exporting the function.
use relicario_cli::restore_hardening::safe_unpack_git_archive;

fn build_traversal_tar() -> Vec<u8> {
    let mut buf = Vec::new();
    {
        let mut b = Builder::new(&mut buf);
        let mut h = Header::new_gnu();
        h.set_size(4);
        h.set_mode(0o644);
        h.set_cksum();
        b.append_data(&mut h, "../../escaped.txt", &b"hax!"[..]).unwrap();
        b.finish().unwrap();
    }
    buf
}

fn build_absolute_path_tar() -> Vec<u8> {
    let mut buf = Vec::new();
    {
        let mut b = Builder::new(&mut buf);
        let mut h = Header::new_gnu();
        h.set_size(4);
        h.set_mode(0o644);
        h.set_cksum();
        b.append_data(&mut h, "/etc/escaped.txt", &b"hax!"[..]).unwrap();
        b.finish().unwrap();
    }
    buf
}

fn build_symlink_tar() -> Vec<u8> {
    let mut buf = Vec::new();
    {
        let mut b = Builder::new(&mut buf);
        let mut h = Header::new_gnu();
        h.set_entry_type(tar::EntryType::Symlink);
        h.set_size(0);
        h.set_mode(0o777);
        h.set_cksum();
        // link target points outside dest
        b.append_link(&mut h, "evil-link", "/etc/passwd").unwrap();
        b.finish().unwrap();
    }
    buf
}

fn build_oversize_tar() -> Vec<u8> {
    let mut buf = Vec::new();
    {
        let mut b = Builder::new(&mut buf);
        let mut h = Header::new_gnu();
        // Claim 2 GiB but only write a few bytes -- the size cap should
        // trip on the header before unpacking the body.
        h.set_size(2 * 1024 * 1024 * 1024);
        h.set_mode(0o644);
        h.set_cksum();
        // append_data needs to write the claimed bytes; instead we use
        // append() which respects the header size and read from a sparse
        // source.
        let zeros = std::io::Cursor::new(vec![0u8; 4096]);
        // For test purposes: just write a header with a huge claimed size
        // and a small body; the cap should reject before reading.
        b.append(&h, std::io::Read::take(zeros, 4096)).unwrap();
        b.finish().ok();
    }
    buf
}

#[test]
fn restore_rejects_path_traversal() {
    let dest = TempDir::new().unwrap();
    let bytes = build_traversal_tar();
    let err = safe_unpack_git_archive(&bytes, dest.path(), 1024 * 1024).unwrap_err();
    let msg = format!("{err:#}");
    assert!(msg.contains("path traversal") || msg.contains(".."),
        "expected path-traversal rejection, got: {msg}");
    // And: the escape destination must not exist.
    let escaped = dest.path().parent().unwrap().join("escaped.txt");
    assert!(!escaped.exists(), "traversal succeeded -- {} exists", escaped.display());
}

#[test]
fn restore_rejects_absolute_path() {
    let dest = TempDir::new().unwrap();
    let bytes = build_absolute_path_tar();
    let err = safe_unpack_git_archive(&bytes, dest.path(), 1024 * 1024).unwrap_err();
    let msg = format!("{err:#}");
    assert!(msg.contains("absolute") || msg.contains("path traversal"),
        "expected absolute-path rejection, got: {msg}");
}

#[test]
fn restore_rejects_symlink() {
    let dest = TempDir::new().unwrap();
    let bytes = build_symlink_tar();
    let err = safe_unpack_git_archive(&bytes, dest.path(), 1024 * 1024).unwrap_err();
    let msg = format!("{err:#}");
    assert!(msg.contains("symlink") || msg.contains("link"),
        "expected symlink rejection, got: {msg}");
}

#[test]
fn restore_rejects_size_bomb() {
    let dest = TempDir::new().unwrap();
    let bytes = build_oversize_tar();
    // Cap of 1 KiB; entry claims 2 GiB.
    let err = safe_unpack_git_archive(&bytes, dest.path(), 1024).unwrap_err();
    let msg = format!("{err:#}");
    assert!(msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
        "expected size-cap rejection, got: {msg}");
}

#[test]
fn restore_accepts_normal_files() {
    let dest = TempDir::new().unwrap();
    // Build a tiny well-formed tar with one regular file in subdir/.
    let mut buf = Vec::new();
    {
        let mut b = Builder::new(&mut buf);
        let mut h = Header::new_gnu();
        h.set_size(5);
        h.set_mode(0o644);
        h.set_cksum();
        b.append_data(&mut h, "subdir/hello.txt", &b"hello"[..]).unwrap();
        b.finish().unwrap();
    }
    safe_unpack_git_archive(&buf, dest.path(), 1024 * 1024).expect("happy path");
    let written = dest.path().join("subdir").join("hello.txt");
    assert!(written.exists());
    assert_eq!(std::fs::read(&written).unwrap(), b"hello");
}
```

NOTE: integration tests in `crates/<x>/tests/` can only access `pub` items from the crate's library target. `relicario-cli` is currently binary-only. We have two options:

- (A) Convert `relicario-cli` to bin+lib by adding `crates/relicario-cli/src/lib.rs` that `pub use`s the helper module, and let `main.rs` use the lib.
- (B) Move `safe_unpack_git_archive` into `relicario-core` (since it's pure logic with no CLI deps) and unit-test it there.

Pick **B** — it belongs in core (no filesystem-trust assumptions are CLI-specific), it keeps `relicario-cli` binary-only, and it matches the project's "core is bytes-in/bytes-out" principle. The function takes `&[u8]` for the tar bytes and `&Path` for the destination, which is acceptable since it does fs writes. Or, more strictly: have the function emit `Vec<(PathBuf, Vec<u8>)>` of cleaned entries and let the CLI write them. **Choose the cleaner variant**: emit a `Vec<(RelPath, Vec<u8>)>` from core, let the CLI write.

Adjust the tests accordingly:

- Test target: `crates/relicario-core/tests/safe_unpack.rs` (new file).
- Function under test: `pub fn safe_unpack_git_archive(tar_bytes: &[u8], max_uncompressed_bytes: u64) -> Result<Vec<(PathBuf, Vec<u8>)>>` in `crates/relicario-core/src/backup.rs` (or a new `crates/relicario-core/src/tar_safe.rs`).

Move the test file to `crates/relicario-core/tests/safe_unpack.rs` and adjust the assertions: instead of `dest.path()`-based checks, assert the returned `Vec` is `Err` for malicious cases and contains the expected entry for the happy path.

- [ ] **Step 3: Run tests to confirm fail**

Run: `cargo test -p relicario-core --test safe_unpack`
Expected: compile error — `safe_unpack_git_archive` doesn't exist yet.

NOTE: do not commit yet — paired with the implementation in Task 6.

---

## Task 6: S2 — Implement `safe_unpack_git_archive` in core, wire into CLI

**Files:**
- Create or modify: `crates/relicario-core/src/tar_safe.rs` (new module)
- Modify: `crates/relicario-core/src/lib.rs` (export module)
- Modify: `crates/relicario-cli/src/main.rs` (replace the `archive.unpack(...)` call)

- [ ] **Step 1: Add `tar` to relicario-core dependencies**

Edit `crates/relicario-core/Cargo.toml`:

```toml
tar = { version = "0.4", default-features = false }
```

(Same version as relicario-cli already uses — keep workspace consistent.)

- [ ] **Step 2: Implement the function**

Write `crates/relicario-core/src/tar_safe.rs`:

```rust
//! Hardened tar archive extraction used by backup restore.
//!
//! Defends against:
//!  - Path traversal: entries with `..` components.
//!  - Absolute paths and Windows drive letters.
//!  - Symlinks and hardlinks (we never legitimately create either inside
//!    `.git/` on restore).
//!  - Tar bombs: caller provides an uncompressed-size cap.

use std::io::Read;
use std::path::{Component, Path, PathBuf};

use crate::error::{RelicarioError, Result};

/// Maximum default cap if the caller doesn't have a better number: 1 GiB.
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;

/// Decode `tar_bytes` and return a list of (relative-path, contents) pairs
/// for regular files only. Caller writes them to disk.
///
/// Any malicious-looking entry causes the entire operation to fail. We do
/// not partially-extract.
pub fn safe_unpack_git_archive(
    tar_bytes: &[u8],
    max_uncompressed_bytes: u64,
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
    let mut archive = tar::Archive::new(tar_bytes);
    let mut out: Vec<(PathBuf, Vec<u8>)> = Vec::new();
    let mut total_size: u64 = 0;

    for entry in archive.entries().map_err(|e| RelicarioError::BackupRestore(format!("tar entries: {e}")))? {
        let mut entry = entry.map_err(|e| RelicarioError::BackupRestore(format!("tar entry: {e}")))?;

        // 1. Reject non-regular entries outright.
        match entry.header().entry_type() {
            tar::EntryType::Regular | tar::EntryType::Continuous | tar::EntryType::GNUSparse => {}
            tar::EntryType::Directory => continue, // dirs are implicit in file paths
            tar::EntryType::Symlink | tar::EntryType::Link => {
                return Err(RelicarioError::BackupRestore(format!(
                    "rejecting symlink/hardlink entry: {} (path traversal blocked)",
                    entry.path().map(|p| p.display().to_string()).unwrap_or_default()
                )));
            }
            other => {
                return Err(RelicarioError::BackupRestore(format!(
                    "rejecting unexpected tar entry type {other:?} (path traversal blocked)"
                )));
            }
        }

        // 2. Size cap. Check declared size in header before reading the body.
        let claimed = entry.header().size().map_err(|e| {
            RelicarioError::BackupRestore(format!("tar header size: {e}"))
        })?;
        if claimed > max_uncompressed_bytes {
            return Err(RelicarioError::BackupRestore(format!(
                "tar entry declared size {claimed} bytes exceeds cap {max_uncompressed_bytes}"
            )));
        }
        total_size = total_size.saturating_add(claimed);
        if total_size > max_uncompressed_bytes {
            return Err(RelicarioError::BackupRestore(format!(
                "tar entries total size {total_size} bytes exceeds cap {max_uncompressed_bytes}"
            )));
        }

        // 3. Path validation.
        let raw_path = entry.path()
            .map_err(|e| RelicarioError::BackupRestore(format!("tar entry path: {e}")))?
            .into_owned();
        let cleaned = validate_relative_path(&raw_path)?;

        // 4. Read body (bounded by the size cap above).
        let mut body = Vec::with_capacity(claimed as usize);
        entry.read_to_end(&mut body)
            .map_err(|e| RelicarioError::BackupRestore(format!("read tar entry body: {e}")))?;

        out.push((cleaned, body));
    }

    Ok(out)
}

/// Reject any path that contains `..`, an absolute prefix, a Windows drive
/// letter, or a non-normal component. Returns a path made of only `Normal`
/// components.
fn validate_relative_path(p: &Path) -> Result<PathBuf> {
    let mut cleaned = PathBuf::new();
    for comp in p.components() {
        match comp {
            Component::Normal(s) => cleaned.push(s),
            Component::CurDir => {} // "./foo" -> "foo"
            Component::ParentDir => {
                return Err(RelicarioError::BackupRestore(format!(
                    "tar entry contains `..` component: {} (path traversal blocked)",
                    p.display()
                )));
            }
            Component::RootDir => {
                return Err(RelicarioError::BackupRestore(format!(
                    "tar entry has absolute path: {} (path traversal blocked)",
                    p.display()
                )));
            }
            Component::Prefix(_) => {
                // Windows drive letter or UNC prefix.
                return Err(RelicarioError::BackupRestore(format!(
                    "tar entry has drive/UNC prefix: {} (path traversal blocked)",
                    p.display()
                )));
            }
        }
    }
    if cleaned.as_os_str().is_empty() {
        return Err(RelicarioError::BackupRestore(format!(
            "tar entry has empty path after normalisation: {}",
            p.display()
        )));
    }
    Ok(cleaned)
}
```

- [ ] **Step 3: Add the `BackupRestore` error variant if missing**

Read `crates/relicario-core/src/error.rs`. If `BackupRestore(String)` doesn't exist as a variant, add it:

```rust
#[error("backup restore: {0}")]
BackupRestore(String),
```

If a similar variant already exists (e.g. `Backup`, `Restore`, `Tar`), use that instead and adjust the function above.

- [ ] **Step 4: Wire module into lib.rs**

Edit `crates/relicario-core/src/lib.rs`:

```rust
pub mod tar_safe;
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
```

- [ ] **Step 5: Run unit tests for the function**

Run: `cargo test -p relicario-core --test safe_unpack`
Expected: all 5 tests pass (4 reject cases + 1 happy path).

- [ ] **Step 6: Replace the unsafe `archive.unpack` call in CLI**

Edit `crates/relicario-cli/src/main.rs`. Find the block at line 1719-1723:

```rust
    // .git/ history.
    if let Some(tar_bytes) = &unpacked.git_archive {
        let mut archive = tar::Archive::new(tar_bytes.as_slice());
        archive.unpack(target.join(".git"))
            .with_context(|| "failed to untar .git/")?;
    } else {
```

Replace with:

```rust
    // .git/ history. Hardened against path traversal, symlinks, and tar bombs.
    if let Some(tar_bytes) = &unpacked.git_archive {
        // Cap: 100x the compressed bundle, or 1 GiB, whichever is lower.
        // 100x is a generous heuristic; legitimate .git pack data
        // compresses 3-10x, so 100x leaves room for unusual repos.
        let cap = std::cmp::min(
            (tar_bytes.len() as u64).saturating_mul(100),
            relicario_core::tar_safe::DEFAULT_MAX_UNCOMPRESSED,
        );
        let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
            .with_context(|| "failed to safely unpack .git/ archive")?;
        let git_dir = target.join(".git");
        for (rel_path, body) in entries {
            let dest = git_dir.join(&rel_path);
            // Final paranoid check: ensure dest is inside git_dir even after
            // OS-level resolution. We did the textual check in core; this
            // catches any platform-specific quirk.
            if !dest.starts_with(&git_dir) {
                anyhow::bail!(
                    "tar entry {} resolved outside .git/ (path traversal blocked)",
                    rel_path.display()
                );
            }
            if let Some(parent) = dest.parent() {
                fs::create_dir_all(parent).with_context(|| {
                    format!("failed to create parent {}", parent.display())
                })?;
            }
            fs::write(&dest, &body).with_context(|| {
                format!("failed to write {}", dest.display())
            })?;
        }
    } else {
```

- [ ] **Step 7: Run the full test suite**

Run: `cargo test`
Expected: all tests pass — including the existing happy-path backup roundtrip in `crates/relicario-cli/tests/backup.rs` (or wherever the existing restore test lives — confirm it's still green).

- [ ] **Step 8: Commit**

```bash
git add crates/relicario-core/Cargo.toml \
        crates/relicario-core/src/tar_safe.rs \
        crates/relicario-core/src/lib.rs \
        crates/relicario-core/src/error.rs \
        crates/relicario-core/tests/safe_unpack.rs \
        crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(core,cli): harden backup-restore tar unpack (audit S2)

`cmd_backup_restore` previously called tar::Archive::unpack on the
embedded .git archive with default settings, trusting the tar crate's
defaults to reject malicious payloads. They don't reject hardlinks,
they don't enforce size caps, and an attacker-crafted .relbak with
`../../etc/passwd` entries could escape the target directory.

Replaced with `relicario_core::safe_unpack_git_archive`, which:

- Rejects entries with `..` components, absolute paths, or Windows
  drive prefixes (Component::ParentDir/RootDir/Prefix).
- Rejects symlinks and hardlinks outright -- legitimate .git/ tar
  archives produced by `relicario backup export` only contain regular
  files and directories.
- Enforces a max-uncompressed-bytes cap (caller picks; CLI uses
  min(100x compressed, 1 GiB)).
- Returns (path, bytes) pairs to the caller; the CLI writes them and
  re-checks `dest.starts_with(git_dir)` for OS-level paranoia.

Acceptance: 5 unit tests in relicario-core (path traversal, absolute
path, symlink, size bomb, happy path) plus existing CLI restore
roundtrip stays green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: S3 — Audit `RELICARIO_*` env vars and document them in SECURITY.md

**Files:**
- Modify: `docs/SECURITY.md`

The full set of `RELICARIO_*` env vars in production code (after the test-gate work in the previous device-auth plan):

| Var | Site | Trust |
|---|---|---|
| `RELICARIO_VAULT` | `crates/relicario-cli/src/helpers.rs:vault_dir` (referenced via doc-comment at line 172) | User-trusted: vault-root override |
| `RELICARIO_IMAGE` | `crates/relicario-cli/src/session.rs:125` | User-trusted: reference-image path override |
| `RELICARIO_NO_GROUPS_CACHE` | `crates/relicario-cli/src/helpers.rs:103` | **Dev-only** — to be cfg-gated in Task 8 |
| `RELICARIO_GITEA_URL` | `crates/relicario-cli/src/main.rs:2298` | User config (Gitea base URL) |
| `RELICARIO_GITEA_TOKEN` | `crates/relicario-cli/src/main.rs:2303` | User secret (Gitea API token) |
| `RELICARIO_GITEA_OWNER` | `crates/relicario-cli/src/main.rs:2308` | User config (repo owner) |
| `RELICARIO_GITEA_REPO` | `crates/relicario-cli/src/main.rs:2313` | User config (repo name) |

Plus the `cfg(test)`-gated `RELICARIO_TEST_*` set (already gated by the device-auth plan): `RELICARIO_TEST_PASSPHRASE`, `RELICARIO_TEST_ITEM_SECRET`, `RELICARIO_TEST_BACKUP_PASSPHRASE`, `RELICARIO_TEST_ITEM_PASSWORD`. These are test-binary-only and should be documented as such (so security reviewers don't flag them again).

Confirm this list with a grep before writing — actual sites win over the spec's three-item list.

- [ ] **Step 1: Verify the list**

Run: `grep -rn 'RELICARIO_' crates/ | grep -v '/target/' | grep -v '/tests/' | grep 'env::'`
Expected: matches the table above (give or take `RELICARIO_TEST_*` which is what we're auditing). Update the table if any new vars have appeared since this plan was drafted.

- [ ] **Step 2: Append a "Configuration env vars" section to SECURITY.md**

Edit `docs/SECURITY.md`. Add at the bottom (after the existing "Access Control" section):

```markdown
## Configuration env vars

Relicario reads the following environment variables. Each one is a
trust boundary: an attacker who can set them in the user's environment
can influence Relicario's behavior. They are listed here so security
reviewers can audit the surface in one place.

### User-facing

| Variable | Purpose | Trust |
|---|---|---|
| `RELICARIO_VAULT` | Override the vault root directory (default: `~/.relicario`). | Trusted: filesystem path under the user's control. |
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during unlock. | Trusted: filesystem path under the user's control. The image file is read-only; its bytes feed `imgsecret::extract_secret`. |
| `RELICARIO_GITEA_URL` | Default Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via Gitea. The CLI does not log it. |
| `RELICARIO_GITEA_OWNER` | Repo owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
| `RELICARIO_GITEA_REPO` | Repo name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |

### Test-only (compiled out of release builds)

The following are gated behind `#[cfg(test)]` and **do not exist** in
binaries built without the test profile (`cargo build --release`):

| Variable | Purpose |
|---|---|
| `RELICARIO_TEST_PASSPHRASE` | Bypass the rpassword prompt during integration tests. |
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the rpassword prompt for item-secret fields during integration tests. |
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the prompt for backup export/restore passphrases during integration tests. |
| `RELICARIO_TEST_ITEM_PASSWORD` | Bypass the rpassword prompt for legacy item-password fields during integration tests. |

### Dev-only (compiled out of release builds via `cfg(debug_assertions)`)

| Variable | Purpose |
|---|---|
| `RELICARIO_NO_GROUPS_CACHE` | Disables the plaintext groups cache used for shell-completion. Intended for developers debugging the cache logic. Compiled out of `cargo build --release` builds. |
```

- [ ] **Step 3: Commit**

```bash
git add docs/SECURITY.md
git commit -m "$(cat <<'EOF'
docs: enumerate RELICARIO_* env vars in SECURITY.md (audit S3)

Adds a "Configuration env vars" section listing every RELICARIO_*
variable read by production code, with purpose and trust boundary.
Splits user-facing vars from test-only and dev-only ones to make the
attack surface explicit for security reviewers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: S3 — `cfg(debug_assertions)`-gate `RELICARIO_NO_GROUPS_CACHE`

**Files:**
- Modify: `crates/relicario-cli/src/helpers.rs`
- Modify: `crates/relicario-cli/src/main.rs` (doc-comment at line 174)

`RELICARIO_NO_GROUPS_CACHE` is a developer escape hatch for debugging the groups-cache logic, not a user knob. Gating it with `cfg(debug_assertions)` makes the env-var read no-op in `cargo build --release` (since debug assertions are off in release by default), which means `strings` on the release binary won't show the variable name and the env-var lookup is dead-code-eliminated.

- [ ] **Step 1: Write a build-mode-aware test**

In `crates/relicario-cli/src/helpers.rs`, find the existing `tests` module (or add one) and append:

```rust
#[cfg(test)]
mod tests {
    // ... existing tests ...

    /// In debug builds (which `cargo test` always is) the override should still
    /// work so existing dev workflows keep functioning.
    #[test]
    #[cfg(debug_assertions)]
    fn no_groups_cache_env_var_honored_in_debug() {
        use std::collections::BTreeSet;
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("RELICARIO_NO_GROUPS_CACHE", "1");
        super::write_groups_cache(tmp.path(), &BTreeSet::new()).unwrap();
        std::env::remove_var("RELICARIO_NO_GROUPS_CACHE");
        assert!(!tmp.path().join(".relicario/groups.cache").exists(),
            "env var should suppress the cache write in debug builds");
    }
}
```

- [ ] **Step 2: Run test to confirm baseline (still passes)**

Run: `cargo test -p relicario-cli helpers::tests::no_groups_cache_env_var_honored_in_debug`
Expected: passes — current code already honours the var in debug.

- [ ] **Step 3: Gate the env-var check**

Edit `crates/relicario-cli/src/helpers.rs`. Find the function (line 99-115ish):

```rust
pub fn write_groups_cache(
    vault_dir: &Path,
    groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
    if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
        return Ok(());
    }
    // ...
```

Change to:

```rust
pub fn write_groups_cache(
    vault_dir: &Path,
    groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
    // Dev-only escape hatch. Compiled out of release builds: the
    // const below is `false` under `cargo build --release`, which lets
    // the optimiser drop the env-var lookup entirely (so `strings` on
    // a release binary won't reveal the variable name).
    if cfg!(debug_assertions)
        && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some()
    {
        return Ok(());
    }
    // ... rest unchanged
```

Update the doc-comment a few lines up to reflect the new behaviour:

```rust
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
/// suppresses the write (used by tests). In release builds the env var is
/// ignored.
```

- [ ] **Step 4: Run the test again and the rest of the suite**

Run: `cargo test -p relicario-cli`
Expected: all tests pass.

- [ ] **Step 5: Update the `cmd_groups` doc-comment in main.rs**

Find lines 170-176ish in `crates/relicario-cli/src/main.rs`:

```rust
    /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
    /// ...
    /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
    /// will fall back to vault-locked behaviour).
```

Adjust to reflect that `RELICARIO_NO_GROUPS_CACHE` is debug-only:

```rust
    /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
    /// ...
    /// In debug builds only, set `RELICARIO_NO_GROUPS_CACHE=1` to opt out
    /// of the cache (completion will fall back to vault-locked behaviour).
    /// The env var is a no-op in release builds.
```

- [ ] **Step 6: Verify the variable is gone from a release binary**

Run:

```bash
cargo build -p relicario-cli --release
strings target/release/relicario | grep RELICARIO_NO_GROUPS_CACHE && echo "FAIL: still present" || echo "OK: not present"
```

Expected: `OK: not present`.

NOTE: `cfg!(debug_assertions)` evaluates to a `bool` literal at build time. Combined with `&&`, the optimiser sees `if false && ...` in release and dead-codes the whole branch including the env-var lookup. If `strings` somehow still finds it (e.g. due to a stray doc-string referencing the name in `#[command(...)]` long-help), that's expected — what matters is no env-var read at runtime.

- [ ] **Step 7: Commit**

```bash
git add crates/relicario-cli/src/helpers.rs crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(cli): cfg-gate RELICARIO_NO_GROUPS_CACHE to debug builds (audit S3)

The groups-cache opt-out is a developer debugging knob, not a user-facing
config. Gating its env-var lookup behind `cfg!(debug_assertions)` makes
release builds ignore the variable entirely; the optimiser drops the
lookup, removing the variable name from the release binary's strings.

Doc-comments updated to reflect the new behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: C1 — Prune merged local feature branches (interactive)

**Files:**
- _local git state only_ — no source files changed.

The five branches to evaluate (from the spec):
- `feature/fullscreen-ux-phase-2a`
- `feature/typed-items-1a-rust-core`
- `feature/typed-items-1c-alpha`
- `feature/typed-items-1c-beta1`
- `feature/typed-items-1c-beta2`

This task is **destructive** (`git branch -D`). The project rule (`CLAUDE.md`: "Always pause and ask before ... `git branch -D`") applies — prompt the user before each delete.

- [ ] **Step 1: Confirm we're on `main` and clean**

Run:

```bash
git status
git branch --show-current
```

Expected: branch is `main`. Working tree state is the user's call — Cargo.lock is currently modified but that doesn't block branch deletion.

- [ ] **Step 2: List merged branches**

Run:

```bash
git branch --merged main
```

Expected output should include all five target branches plus `* main`. If any are missing, do NOT delete that one — it has unmerged commits. Surface this to the user before continuing.

- [ ] **Step 3: For each branch, confirm-then-delete one at a time**

For each of the five branches in this exact order:

1. `feature/fullscreen-ux-phase-2a`
2. `feature/typed-items-1a-rust-core`
3. `feature/typed-items-1c-alpha`
4. `feature/typed-items-1c-beta1`
5. `feature/typed-items-1c-beta2`

Per branch:

a. Run `git log --oneline main..<branch> | head -5` to show the user what's *not* in main from this branch's perspective. Empty output = fully merged. Non-empty = unmerged commits — STOP and surface to the user, do not delete.

b. Show the user the tip commit for context:

```bash
git log -1 --format='%h %s' <branch>
```

c. **Prompt the user**: "Delete local branch `<branch>` (tip: `<sha> <subject>`)? [y/N]"

d. On `y` (or `yes`): run `git branch -D <branch>`. On anything else: skip and continue to the next.

e. After delete, run `git branch` to confirm the branch is gone.

- [ ] **Step 4: Verify final state**

Run:

```bash
git branch
```

Expected: only `* main` remains (assuming the user said yes to all five). If any branches remain, that's fine — the user chose to keep them.

- [ ] **Step 5: No commit needed**

Branch deletions don't produce commits. There is nothing to commit for C1; the task ends here.

- [ ] **Step 6: Surface a summary to the caller**

Print a one-line summary: "C1 done. Pruned N of 5 merged feature branches." (where N is whatever the user accepted).

---

## Acceptance — final verification

- [ ] **Step 1: All Rust tests green**

```bash
cargo test
```

Expected: all tests pass, including the 4 S1 tests and 5 S2 tests added in this plan.

- [ ] **Step 2: All targets build**

```bash
cargo build
cargo build -p relicario-wasm --target wasm32-unknown-unknown
cargo build -p relicario-server
cargo build -p relicario-cli --release
```

Expected: all succeed.

- [ ] **Step 3: Release binary doesn't reference the dev env var**

```bash
strings target/release/relicario | grep RELICARIO_NO_GROUPS_CACHE && echo "FAIL" || echo "OK"
```

Expected: `OK`.

- [ ] **Step 4: SECURITY.md is up to date**

Confirm `docs/SECURITY.md` lists every `RELICARIO_*` var that appears in `grep -rn 'env::var' crates/ | grep RELICARIO`.

- [ ] **Step 5: No stale local feature branches**

```bash
git branch
```

Expected: matches the user's choices in Task 9.

- [ ] **Step 6: Hand off to user for review and merge**

Summarise the four security/cleanup landings (S1, S2, S3, C1), point at the spec, and stop. The user merges to `main` and tags `v0.5.0` after Plan B is also green.

---

## Completion Checklist

- [ ] Task 1: `device::fingerprint` helper added to relicario-core (S1 prep)
- [ ] Task 2: relicario-server dev-dependencies added (S1 prep)
- [ ] Task 3: 4 failing acceptance tests for verify-commit (S1)
- [ ] Task 4: `verify_commit` rewritten — allowed-signers temp file, fingerprint matching, devices/revoked check with committer-date semantics (S1)
- [ ] Task 5: 5 failing tests for `safe_unpack_git_archive` (S2)
- [ ] Task 6: `safe_unpack_git_archive` implemented in core, CLI restore wired through it (S2)
- [ ] Task 7: SECURITY.md "Configuration env vars" section added (S3)
- [ ] Task 8: `RELICARIO_NO_GROUPS_CACHE` cfg-gated to debug builds (S3)
- [ ] Task 9: Merged local feature branches pruned interactively (C1)
- [ ] Final acceptance walk done and Plan A handed off

---

## Out of Scope (do not implement here)

These belong to Plan B (Extension UX), tracked separately at
`docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md` (TBD by the
Plan B author):

- B1 — Strength-meter desync after regenerate (extension/TS)
- B2/P4 — Error-message audit (`vault_locked` raw-code leak), ERROR_COPY map
- P1 — Password coloring
- P2 — Setup-wizard completion routes to fullscreen vault tab
- P3 — Form-layout 2-col -> full-width transition
