# Architecture: relicario-cli ## What this crate is for The `relicario` binary is the platform layer for `relicario-core`: it adds filesystem layout, a hardened `git` shell-out, interactive `rpassword` prompts, clipboard handoff, and a clap-based command surface. The crate has two design roles. First, it is the developer / power-user surface that exposes everything the core can do (every `ItemCore` variant, every `VaultSettings` knob, history inspection, device key management). Second, it is the only working interface during disaster recovery — the extension may be uninstalled, the device may be new — so it intentionally maintains feature parity with the extension's vault tab. It deliberately shells out to `git` rather than depending on libgit2 / gitoxide; this keeps the dep tree slim, lets the user override `git` config locally, and lets recovery debugging happen with familiar tooling. ## Module map The crate is three files of source and a `tests/` directory. Each source file has one job. - **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command handler. Internal structure: a top-level `Cli` / `Commands` enum (`main.rs:13-275`), a flat dispatcher `match` in `main()` (`main.rs:277-303`), per-command handlers named `cmd_`, and a layer of per-type item helpers (`build__item` for `cmd_add`, `edit_` for `cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted ~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions, one per `ItemCore` variant, so each builder/editor reads top-to-bottom and can be tested through the same integration paths. Owns all clap argument parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared `commit_paths` helper that is the single chokepoint for git commits during vault mutations. - **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the key wipes via `Zeroize` on scope exit (`session.rs:22-25`). Owns the `unlock_interactive` flow (vault root walk → salt read → params read → reference image extract → passphrase prompt → KDF) at `session.rs:33-59`, the typed `load_*` / `save_*` accessors for `Item` / `Manifest` / `VaultSettings`, the `read_salt` / `read_params` helpers, the `RELICARIO_IMAGE` lookup, and `atomic_write` (`session.rs:144-151`) which every disk write to a vault file goes through. Owns the env-var escape hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE` (`session.rs:125`) that integration tests use to bypass the TTY. - **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing: `find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it for `cwd`-rooted callers; `git_command` (`helpers.rs:45-55`) is the hardened-`git` factory that every git invocation in the crate (production code, not tests) goes through; `iso8601` (`helpers.rs:60-64`) formats Unix seconds for human-readable output (audit M11). The hardening is load-bearing — see Invariants & Gotchas below. ## Invariants & contracts These are the load-bearing rules the crate relies on. Each has been verified in code; cite the line if you change it. - **Every vault-mutating command unlocks via `UnlockedVault`.** The struct holds the master key in `Zeroizing<[u8; 32]>` and drops via `Zeroize` on scope exit (`session.rs:22-25`). No command bypasses this except `cmd_generate` outside a vault dir and `cmd_init` (which derives the key inline before there is a vault to unlock). - **Every `git` invocation in production code goes through `helpers::git_command`.** A grep for `Command::new("git")` outside `helpers.rs` finds zero hits in `src/`; the only other match is in `tests/edit_and_history.rs:18`, which is test-side verification of the git log and is exempt by design. `git_command` injects `core.hooksPath=/dev/null`, `commit.gpgsign=false`, and `core.editor=true` via `-c` flags (`helpers.rs:48-52`). Direct `Command::new("git")` would bypass the hardening — don't. - **Every file write to a vault file uses `atomic_write`.** `atomic_write` (`session.rs:144-151`) writes `.tmp` then renames over ``; a partial write never appears as the live file. All `UnlockedVault::save_*` helpers route through it. (`cmd_init` writes pre-creation files via `fs::write` at `main.rs:373-393`; that path doesn't need atomicity because the vault doesn't exist yet — failure leaves a half-built vault that the next run rejects via `relicario_dir.exists()` at `main.rs:326`.) - **Every commit during a mutating command uses `commit_paths`.** `commit_paths` (`main.rs:767-775`) does `git add && git commit -m ` through the hardened wrapper. Commit message convention is `: (<id>)` — `add:`, `edit:`, `trash:`, `restore:`, `purge:`, `attach:`, `detach:`, `settings: update`, `device: add <name>`, `device: revoke <name>`, `init: new relicario vault (format v2)`, `trash empty: purged N item(s)`. `cmd_purge` and `cmd_trash_empty` and `cmd_device` use `git_command` directly (not `commit_paths`) because they need a slightly different add/commit pattern; they still go through the hardened wrapper. - **`cmd_generate` is the only command that runs without unlock — and only when invoked outside a vault directory.** Inside a vault, `cmd_generate` unlocks to read `settings.generator_defaults` (`main.rs:1440-1445`); explicit flags override the stored defaults. This is why the smoke-test `cargo run -p relicario-cli -- generate --length 32` works without any setup. - **Item IDs are minted by core.** The CLI never constructs an `ItemId` directly; `Item::new` (called inside every `build_*_item`) does it via `relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex. - **Manifest is always saved last.** Within a single command, the order is: write item file → mutate manifest → save manifest → commit. If the process dies between step 1 and step 3, the next run sees an item file with no manifest entry; `cmd_status` / `cmd_list` ignore it because they read the manifest, not the directory. (Recovery would manually re-`add` to surface it.) - **Vault root is always discovered, never assumed to be `cwd`.** `helpers::vault_dir` walks up from `cwd` looking for `.relicario/`, so any command run from a subdirectory of the vault works (verified by `vault_detection.rs:23-40`). v1 vaults using `.idfoto/` are naturally rejected because they don't contain `.relicario/` — no compat shim needed (`vault_detection.rs:42-59`). - **`prompt_secret` reads `RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.** This is the only way integration tests can drive the per-item secret prompts (Login password, Card number, TOTP secret rotation, Key material) without a real TTY. The check is at `main.rs:308-313`. ## Key flows ### Vault init (`cmd_init`, `main.rs:315-418`) 1. Refuse if `.relicario/` already exists (`main.rs:326-328`). 2. Read passphrase twice (or once via `RELICARIO_TEST_PASSPHRASE`); confirm they match; run `validate_passphrase_strength` (zxcvbn-backed) and bail with audit-H3 message on weak input (`main.rs:331-348`). 3. Generate a 32-byte random `image_secret` via `OsRng`, embed it into the carrier JPEG via `imgsecret::embed`, write the stego output to `--output` (`main.rs:351-360`). 4. Generate a 32-byte salt and pin `KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }` (production-grade) at `main.rs:363-365`. 5. `derive_master_key(passphrase, image_secret, salt, params)` → `Zeroizing<[u8;32]>` (`main.rs:368`). 6. Create `.relicario/`, `items/`, `attachments/` dirs; write `.relicario/{salt, params.json, devices.json}`; encrypt and write `manifest.enc` (empty `Manifest::new()`) and `settings.enc` (`VaultSettings::default()`) (`main.rs:370-393`). 7. Write `.gitignore` listing the reference image filename (so the second factor never accidentally ends up in git) (`main.rs:396-400`). 8. `git init` then initial commit `init: new relicario vault (format v2)` via `git_command` (`main.rs:403-412`). Note the initial commit does NOT go through `commit_paths` — it precedes the existence of an `UnlockedVault`, so the path list is hand-spelled. ### Vault unlock (`UnlockedVault::unlock_interactive`, `session.rs:33-59`) 1. `vault_dir()` walks up from cwd to find `.relicario/`; bails with the "run `relicario init` first" message on miss (`helpers.rs:21-26`). 2. `read_salt` reads `.relicario/salt` (32 bytes; rejects any other length). 3. `read_params` deserializes `.relicario/params.json` and extracts the nested `kdf` sub-object as `KdfParams` (`session.rs:110-121`). The nested shape exists because `params.json` also stores `format_version`, `aead`, and `salt_path` for forward-compat probing. 4. `get_image_path` honours `RELICARIO_IMAGE`, then a `<vault>/reference.jpg` convention, then prompts (`session.rs:124-140`). 5. Read the reference image bytes; `imgsecret::extract` runs the DCT majority-vote decode to recover the 32-byte image secret (`session.rs:38-40`). 6. Read the passphrase via `RELICARIO_TEST_PASSPHRASE` or `rpassword` (`session.rs:42-49`). 7. `derive_master_key` produces the master key; `UnlockedVault { root, master_key }` is returned and lives until the command function returns. ### Item add (`cmd_add`, `main.rs:419-456`) 1. Unlock the vault and load the manifest. 2. Match on the `AddKind` variant and dispatch to the matching `build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven builders; only `build_document_item` takes `&UnlockedVault` because it needs `attachment_caps` and writes the encrypted blob alongside the item. 3. The builder returns a fully-populated `Item` (with title, group, tags, favorite-flag, primary attachment if any). 4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`, `vault.save_manifest(&manifest)`. 5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one `attachments/<id>/<aid>.enc` per attachment — and call `commit_paths` with message `add: <title> (<id>)` (`main.rs:444-452`). ### Item edit (`cmd_edit`, `main.rs:938-977`) 1. Unlock, load manifest, resolve query → item id, load the item. 2. Universally-editable fields (title, group, tags) are prompted via `prompt_keep` / `prompt_keep_opt` first; blank input keeps the current value (`main.rs:952-956`). 3. Borrow `&mut item.field_history` once into a local `history` binding (`main.rs:958`), then `match` on `&mut item.core` and dispatch to the per-type `edit_<type>` helper (`main.rs:959-967`). The history-tracking editors (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`) take `&mut FieldHistory`; the others (`edit_identity`, `edit_document_message`) don't. 4. Each editor that mutates a tracked secret calls `push_history(history, "<key>", old_value)` (`main.rs:1095-1109`) — see the History flow below for the synthetic-key convention. 5. `item.modified = now_unix()`, save, upsert manifest, commit `edit: <title> (<id>)`. `edit_document_message` (`main.rs:1050-1052`) just prints "use `attach` / `extract` instead" — Document items can't be field-edited; they're attachment-shaped. The `FieldHistory` type alias (`main.rs:983-986`) is purely cosmetic; it exists so the editor signatures don't have to spell out the full `HashMap<FieldId, Vec<FieldHistoryEntry>>`. ### History capture and view (`push_history` + `cmd_history`) `push_history` (`main.rs:1095-1109`) records an old value under a synthetic `FieldId(format!("core:{key}"))`. The `core:` prefix namespaces these keys so they can never collide with real custom-field UUIDs from the typed-item custom-fields work. The keys used in the codebase are: - `core:login_password` (`main.rs:998`) - `core:secure_note_body` (`main.rs:1012`) - `core:card_number` (`main.rs:1031`) - `core:key_material` (`main.rs:1045`) - `core:totp_secret` (`main.rs:1063`) `cmd_history` (`main.rs:1111-1159`) reads `item.field_history`, sorts the keys, strips the `core:` prefix for display, and prints each entry list masked or revealed depending on `--show`. The `--field <name>` filter matches against either the stripped name (`login_password`) or the raw key (`core:login_password`) so both forms work (`main.rs:1126-1129`). The `relicario history bank --field totp_secret` form is what `edit_and_history.rs` exercises. ### Trash & purge (`cmd_rm` / `cmd_restore` / `cmd_purge` / `cmd_trash_empty`) - `cmd_rm` (`main.rs:1161-1176`) calls `Item::soft_delete()` (sets `trashed_at`), saves, upserts manifest, commits `trash:`. - `cmd_restore` (`main.rs:1178-1193`) is the inverse: `Item::restore()`, same wrap-up, commit `restore:`. - `cmd_purge` (`main.rs:1220-1237`) calls `purge_item` (`main.rs:1197-1218`) which removes the item file, the attachment dir, the manifest entry, and `git rm -rf --ignore-unmatch`s the paths. Then a single `git add manifest.enc` + commit `purge: <title> (<id>)`. - `cmd_trash_empty` (`main.rs:1246-1282`) is the only multi-item mutating command. It loads settings once, iterates all items past their `trash_retention` window, calls `purge_item` for each, then does a single `git add manifest.enc` + commit `trash empty: purged N item(s)`. The single-unlock-per-batch shape was the fix in commit `b5015b3` — the earlier version re-prompted for the passphrase per item. ### Attach / detach / extract - `cmd_attach` (`main.rs:1283-1339`) loads `attachment_caps` from settings and rejects if the item has hit `per_item_max_count`. `encrypt_attachment` enforces `per_attachment_max_bytes`. The encrypted blob lands at `attachments/<item_id>/<aid>.enc`; the `aid` is content-addressed by core. Commit message: `attach: <file> → <title> (<id>)`. - `cmd_detach` (`main.rs:1376-1424`, added in `3f0f5b1`) removes one attachment from the item, deletes the encrypted blob, rewrites the item. Refuses if the target `aid` is a `Document` item's `primary_attachment` (`main.rs:1392-1400`) — that would orphan the item; use `purge` instead. Commit message: `detach: <filename> from <title> (<id>)`. - `cmd_extract` (`main.rs:1354-1375`) decrypts the blob and writes the plaintext to `--out` or to `<filename>` in cwd. Read-only: no commit, no state mutation. - `cmd_attachments` (`main.rs:1341-1352`) lists `aid`, size, mime, filename — read-only. ### Generate (`cmd_generate`, `main.rs:1426-1489`) Has two distinct modes: - **Outside a vault** — `vault_dir()` returns `Err`; `vault_defaults` stays `None`; defaults are hard-coded (`length: 20`, `symbols: SafeOnly`, `words: 5`, `separator: " "`, `Capitalization::Lower`). No unlock prompt. - **Inside a vault** — `vault_dir()` succeeds; full unlock; load `settings.generator_defaults`. Explicit flags override the stored defaults field-by-field. `--bip39` flips mode; absent that flag, the mode is whatever the stored default is. Tests: `settings.rs::generate_uses_vault_default_length` (length-tracking) and `basic_flows.rs::generate_random_and_bip39` (no-vault smoke). The two-mode shape is deliberate (see Gotchas) and is why `cmd_generate` is the only command outside `cmd_init` that touches `helpers::vault_dir()` directly instead of going through `UnlockedVault::unlock_interactive()`. ### Sync (`cmd_sync`, `main.rs:1582-1590`) `git pull --rebase` then `git push`, both via the hardened wrapper. No unlock — sync moves opaque ciphertext, the master key is never needed. This is the only command that fails on conflict; it doesn't try to resolve. Resolution happens manually in the user's git tooling. ### Status (`cmd_status`, `main.rs:1592-1631`, added in `3f0f5b1`) Unlocks; loads manifest; counts items (active vs trashed), attachments (count + total bytes), devices (parsed from `devices.json`); shells out to `git log -1 --pretty=format:%h %s` for the last-commit summary line. All read-only — no commit, no state change. ### Device management (`cmd_device`, `main.rs:1632-1702`) Add: generate ed25519 keypair via `OsRng`, append `{name, public_key}` to `.relicario/devices.json`, write the secret signing key to `<config_dir>/relicario/devices/<name>.key` with `0o600` on Unix, commit `device: add <name>`. List: print `name pubkey_hex`. Revoke: filter by name, rewrite `devices.json`, commit `device: revoke <name>`. Note that device keys are kept entirely separate from the KDF (passphrase × image stays unchanged across device add/revoke), as per the design spec. ### Backup-passphrase-style commands (none yet) The import / export / `import-lastpass` commands described in `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are not yet implemented. When they land they'll fit in the dispatcher (`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here until that work begins. ## Cross-cutting concerns - **Error model.** Every `cmd_*` returns `anyhow::Result<()>`. Core errors bubble up through `?` from `RelicarioError`. Per-step context is added via `with_context(|| ...)` chains, e.g. `format!("failed to read {}", path.display())`. AEAD authentication failures intentionally surface as the ambiguous "wrong passphrase or corrupt vault" message from core — the CLI does not differentiate. clap argument errors are produced by clap (e.g., `--days` and `--forever` together fail at the `SettingsAction::TrashRetention` arm in `main.rs:1504-1510`). - **Atomicity.** Every disk write to a vault file goes through `session.rs::atomic_write` (`session.rs:144-151`): write `<path>.tmp`, then rename over `<path>`. Manifest is the single source of truth and is always written *last* in any multi-file operation, so a process kill between item-write and manifest-write leaves an orphan item file (which doesn't appear in `list`/`status`) but never a manifest pointing to a missing file. - **Git history as audit log.** Per-action commits, never amended, never squashed. The verb prefix on commit subjects (`add:`, `edit:`, `trash:`, `restore:`, `purge:`, `attach:`, `detach:`, `settings:`, `device:`, `init:`) makes `git log --oneline` a literal audit trail. Tests verify this by greping `git log` directly (e.g., `edit_and_history.rs:18-22`). - **Where secrets live.** - Master key — `UnlockedVault.master_key: Zeroizing<[u8; 32]>` (`session.rs:24`). Wipes on drop. - Image secret — `Zeroizing<[u8; 32]>`, lives only inside `unlock_interactive` until the KDF call (`session.rs:40`). - Passphrase — `Zeroizing<String>` from `rpassword::prompt_password` or the env var (`session.rs:42-49`, `main.rs:333-342`). - Item secrets — `Zeroizing<String>` for `Login.password`, `Card.number`, `Card.cvv`, `Card.pin`, `Key.key_material`, `SecureNote.body`, and `Zeroizing<Vec<u8>>` for `TotpCore.config.secret` (decoded from base32). All flow through core types. - Clipboard copy — `Zeroizing<String>` cloned into the detached 30s auto-clear thread (`main.rs:873-889`). - **Test escape hatches.** Three env vars exist for integration tests; all are read at exactly one site each: - `RELICARIO_TEST_PASSPHRASE` — `session.rs:42` (unlock) and `main.rs:333,338` (init). - `RELICARIO_IMAGE` — `session.rs:125` (image path resolution). - `RELICARIO_TEST_ITEM_SECRET` — `main.rs:309` (`prompt_secret` only). None of them have a production fall-through; absent the var, the code always prompts. They are safe in production binaries because the user would have to set them explicitly. - **Generate-without-unlock is intentional.** It is NOT an oversight. `relicario generate --length 32` is the documented smoke test (see the repo's CLAUDE.md) and works as a standalone CSPRNG password generator outside any vault. Inside a vault it does require unlock — see Gotchas. ## Test architecture All tests are integration tests; there are no `#[cfg(test)]` modules in `src/main.rs` or `src/session.rs`. `helpers.rs` has four unit tests (`helpers.rs:67-100`) that exercise vault-dir walking and `iso8601` formatting in isolation. Everything else is `tests/`. - **`tests/common/mod.rs`** (`117 lines`) — the harness. `TestVault::init()` spins up a fresh `TempDir`, generates a 400×300 JPEG via `make_test_jpeg()` (deterministic noise; no binary fixtures), runs `relicario init --image carrier.jpg --output reference.jpg` with `RELICARIO_TEST_PASSPHRASE` set, and stashes the passphrase + reference image path on the struct. `run` and `run_with_input` are the two ways to invoke the binary against the test vault: both inherit `RELICARIO_IMAGE` + `RELICARIO_TEST_PASSPHRASE`; the latter pipes extra newlines into stdin (used for interactive prompts that aren't `rpassword`-driven). The note at the top warns Task 23 implementers about the new-item-password rpassword path; the fix landed as `RELICARIO_TEST_ITEM_SECRET` in commit `20350d5`. - **`tests/basic_flows.rs`** (`136 lines`) — covers the init layout (`.relicario/{salt,params.json,devices.json}`, `manifest.enc`, `settings.enc`, `reference.jpg`, `.gitignore`, `.git`); the `params.json` v2 shape; `add login` + `list`; `get` masking semantics (with and without `--show`); the rm/restore/purge cycle including `list --trashed`; and the two-mode `generate` smoke (random length + bip39 word count) run outside a vault. - **`tests/edit_and_history.rs`** (`191 lines`) — drives `edit` end-to-end by piping stdin lines (blank to keep, `y` to confirm) plus `RELICARIO_TEST_ITEM_SECRET` for the rpassword leg. `edit_password_*` verifies the item file is rewritten and the `edit: bank` commit lands. The four `history_command_*` tests cover masked listing, `--show` reveal, "no history captured" output, and per-field filtering. The `edit_totp_rotates_secret_and_captures_history` test (added 2026-04-27 in commit `3f0f5b1` — fixes a stub at the old `main.rs:925`) drives the full TOTP edit including issuer / label / secret rotation. - **`tests/attachments.rs`** (`106 lines`) — `attach`/`attachments`/ `extract` round-trip (verifies the bytes survive the encrypt-decrypt hop); `detach` removes both the attachment ref and the encrypted blob on disk; `detach` rejects an unknown `aid`; `attach` rejects payloads over `per_attachment_max_bytes`. The detach test (`detach_*`) and the cap test were added in `3f0f5b1` / `20350d5` respectively. - **`tests/settings.rs`** (`135 lines`) — `settings show` and `settings trash-retention --days 60` round-trip; the conflicting-flags rejection (`--days` + `--forever`); the `generate_uses_vault_default_length` test that verifies (a) default vault length is 20, (b) updating `settings generator-defaults --length 32` changes the default, (c) explicit `--length 8` overrides the stored default; the multi-shape `cmd_status` smoke; and the `generate_works_outside_vault` test that verifies the no-unlock path works in a bare `TempDir` with no `.relicario/`. - **`tests/vault_detection.rs`** (`59 lines`) — three tests covering audit L8: `list` refuses without a marker; `list` from a nested subdirectory finds the parent `.relicario/`; a v1 `.idfoto/` directory is rejected with the `.relicario` hint in the error message. The whole test suite uses `assert_cmd` to spawn the real binary against a real temp directory, so they exercise actual fs / git / KDF code paths. The KDF runs with the production-grade `m=64MiB, t=3, p=4` parameters in the test path (`main.rs:365`), which is why init takes a noticeable beat in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note applies to `relicario-core` unit tests, not these CLI integration tests. ## Gotchas & non-obvious decisions - **`cmd_generate` runs without unlock outside a vault, but with unlock inside.** This is two ergonomic guarantees in one command. Outside, it's a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any user who installed `relicario` just for the generator. Inside, it consults `settings.generator_defaults` so the user gets the policy they configured. The branch is the `vault_dir().is_ok()` check at `main.rs:1440`. Tests pin both behaviours. - **TOTP edit pushes history under the synthetic key `core:totp_secret`, not `core:totp` or anything else.** This is what `relicario history <query> --field totp_secret` matches against. The naming convention ("type underscore field") is shared across all five history-tracked fields (see Invariants). If you add a new history-tracked field, pick a matching `<type>_<field>` form so the user-facing `--field` filter stays predictable. - **`detach` refuses a Document item's primary attachment.** (`main.rs:1392-1400`) Document items model "this item *is* a file"; the primary blob isn't optional. The error directs the user to `purge` instead. Non-primary attachments on a Document (e.g., a scanned contract with an addendum) detach normally. - **Per-type `build_*_item` / `edit_*` helpers exist by design after the `3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit` carried 217-line `match` arms. The split-out functions are easier to read, easier to test individually (the existing integration tests still drive them through the same paths), and easier to grow when a new `ItemCore` variant lands. Keep this shape — don't fold them back. - **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three reasons. (1) Dep tree: pulling in libgit2 doubles compile time and adds a C dependency. (2) Override surface: users can put any `~/.gitconfig` they want and it Just Works (subject to the hardening flags). (3) Recovery: when something is wrong with a vault, the user can poke around with `git log`, `git show`, `git fsck` directly; the CLI's git interactions are not opaque. - **The hardened-`git` injection set is load-bearing.** `git_command` prepends three `-c` flags before the user-supplied args (`helpers.rs:48-52`): - `core.hooksPath=/dev/null` — a malicious or buggy hook in a cloned vault could otherwise run arbitrary code on every commit. Master key is in memory at the time of commit; this matters. - `commit.gpgsign=false` — if the user has global GPG signing on, the GPG agent prompt would block on `git commit` and hold the master key alive in memory until the user types the passphrase. Disable it for relicario commits. - `core.editor=true` — `true(1)` exits 0 with no output. If `git` decides to drop into `$EDITOR` (rebase conflict markers, missing `-m`), this neutralises it without crashing the rebase. We pass `-m <msg>` ourselves; this flag is the seatbelt. All three were added together in audit H4. A user can still run `git` themselves with their own config to inspect or repair the vault — the hardening only applies to relicario's invocations. - **`cmd_init` uses production-grade `KdfParams { m: 65536, t: 3, p: 4 }`** (`main.rs:365`), even in tests. `RELICARIO_TEST_PASSPHRASE` bypasses the prompt but does not lower the KDF cost. This is a trade-off: integration tests pay the full Argon2id cost (~half a second per init on a modern machine), but the same code path runs in production. Don't lower the params here — the core's test-only fast params are for `relicario-core` unit tests. - **`params.json` has a nested `kdf` object, not a flat one.** `read_params` (`session.rs:110-121`) deserializes via a private `ParamsFile { kdf: KdfParams }` struct. The nesting exists so `format_version`, `aead`, and `salt_path` can co-exist in the same file for forward-compat. An earlier version of `read_params` tried to deserialize the whole file as `KdfParams` and failed silently — that bug was fixed in commit `b263c27`. - **`commit_paths` is the convention but not always the call site.** `cmd_purge`, `cmd_trash_empty`, and `cmd_device` use `git_command` directly because their add/commit pattern doesn't quite fit `commit_paths(vault, msg, &[paths...])`. They still use the hardened wrapper, just at one level lower. If you find yourself writing a new command with the same shape, prefer `commit_paths`; reach for `git_command` directly only when you need the slightly different control flow these three have. - **Initial commit at `cmd_init` does not use `commit_paths`.** Reason: `commit_paths` takes `&UnlockedVault`, but `cmd_init` doesn't construct one — it uses the master key inline before the vault exists. The init commit goes through `git_command` directly (`main.rs:403-412`). This is the only production code site outside `commit_paths` that does so. - **`Lock` is a no-op (`main.rs:301`).** The CLI doesn't cache a session — every command re-derives the master key. The command exists only for UX parity with the extension, where `lock` actually evicts a cached session. Printed message: `no cached session to lock`. - **`resolve_query` accepts an item id or a case-insensitive title substring** (`main.rs:855-871`). Exact id-match wins; otherwise it defers to `Manifest::search`. Multi-hit substring matches are rejected with an "ambiguous" error listing the matched titles. This is why every `cmd_*` that takes a `query: String` (get, edit, history, rm, restore, purge, attach, attachments, extract, detach) works the same way.