From 37740472980bfdebfdd84bb4952176cd75f63c55 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 16:58:28 -0400 Subject: [PATCH] chore(salvage): snapshot org-vault tail uncommitted work before worktree cleanup org_audit.rs (B8 verified-signer test) + the two uncommitted org.rs diffs (item-CRUD B9-B13, status/audit B8) from the wf_22020aea first-run worktrees. All superseded by v0.8.0 main; also committed on the -r2 branches. Kept so nothing is lost when the stale worktrees are removed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD --- .../2026-06-20-org-vault-tail/README.md | 29 + .../f3e-1-org.rs.uncommitted.patch | 899 ++++++++++++++++++ .../f3e-2-statusaudit.uncommitted.patch | 521 ++++++++++ .../org_audit.f3e-2.rs | 156 +++ 4 files changed, 1605 insertions(+) create mode 100644 docs/superpowers/salvage/2026-06-20-org-vault-tail/README.md create mode 100644 docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-1-org.rs.uncommitted.patch create mode 100644 docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-2-statusaudit.uncommitted.patch create mode 100644 docs/superpowers/salvage/2026-06-20-org-vault-tail/org_audit.f3e-2.rs diff --git a/docs/superpowers/salvage/2026-06-20-org-vault-tail/README.md b/docs/superpowers/salvage/2026-06-20-org-vault-tail/README.md new file mode 100644 index 0000000..cac5ecc --- /dev/null +++ b/docs/superpowers/salvage/2026-06-20-org-vault-tail/README.md @@ -0,0 +1,29 @@ +# Salvage — org-vault tail worktrees (2026-06-20) + +Snapshot taken before cleaning up stale worktrees ahead of the v0.8.1 parity lift. +Everything here is **superseded by what shipped in v0.8.0** (`50b5c01`) and is kept +only so nothing is irrecoverably lost when the source worktrees are removed. + +## Provenance + +The v0.8.0 org-vault build had a first run (`wf_22020aea-*`, worktrees under +`.claude/worktrees/`) that left work **uncommitted**, and a second run +(`wf_e65cb9c3-*`, branches `feature/org-vault-tail-{itemcrud,statusaudit}-r2`) +that **committed** the same work. Main ultimately landed equivalent functionality +through the canonical v0.8.0 merge, leaving the `-r2` branches unmerged. + +| File | Source | What it is | Status in main | +|---|---|---|---| +| `org_audit.f3e-2.rs` | untracked `tests/org_audit.rs` in `wf_22020aea-f3e-2` | B8 integration test: verified-signer attribution + non-member rejection against a real signed repo | **Superseded** — `org_lifecycle.rs` + `org_init_signing.rs` cover verified-signer attribution / non-member rejection; `org_lifecycle.rs::audit_format_json_is_valid_and_has_actions` covers the `org audit` command. Also committed (slightly older variant) on `feature/org-vault-tail-statusaudit-r2`. | +| `f3e-1-org.rs.uncommitted.patch` | uncommitted diff in `wf_22020aea-f3e-1` | +884 lines: org item CRUD handlers (B9–B13) | **Shipped** — item CRUD merged in v0.8.0; also committed on `feature/org-vault-tail-itemcrud-r2` (`a3f0777`). | +| `f3e-2-statusaudit.uncommitted.patch` | uncommitted diff in `wf_22020aea-f3e-2` | +476 lines: status + audit handlers (B8) | **Shipped** — status/audit merged in v0.8.0; also committed on `feature/org-vault-tail-statusaudit-r2` (`57fe10e`, `b6d6db0`). | + +## Why it's safe to remove the source worktrees + +- The committed copies live on the `-r2` branches (preserved) and the canonical + functionality is in `main`. +- These three artifacts pin the only *uncommitted* bytes that existed nowhere else. + +If a future audit wants the dedicated `org_audit.rs` test back as a distinct +integration file, restore it from `org_audit.f3e-2.rs` and re-verify it compiles +against the current `commands::org` surface before adding it to `tests/`. diff --git a/docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-1-org.rs.uncommitted.patch b/docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-1-org.rs.uncommitted.patch new file mode 100644 index 0000000..23b9f2e --- /dev/null +++ b/docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-1-org.rs.uncommitted.patch @@ -0,0 +1,899 @@ +diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs +index b0f1bf8..3b40610 100644 +--- a/crates/relicario-cli/src/commands/org.rs ++++ b/crates/relicario-cli/src/commands/org.rs +@@ -329,6 +329,503 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { + } + } + ++// ═══════════ Item CRUD (B9-B13) ═══════════ ++// ++// `org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge` for items ++// stored under `items//.enc`. Each public `run_org_*` ++// wrapper opens the org vault, resolves the calling member by device key, then ++// delegates the actual work to an inner `*_with` fn that takes an already-opened ++// `UnlockedOrgVault` + the caller's `OrgMember`. The split keeps the CRUD logic ++// testable in-process without device-fingerprint plumbing. ++// ++// Supported builders for `org add`/`org edit`: Login, SecureNote, Identity. ++// Card / Key / Document / Totp parity is deferred (those read secrets via ++// rpassword/stdin); see the follow-up note in the plan after B13. ++ ++use relicario_core::{Item, ItemCore}; ++ ++use crate::org_session::UnlockedOrgVault; ++ ++/// Item kinds `org add` supports without interactive prompts. This is the ++/// handler-side enum (no clap attributes, no `collection`/`tags` — those are ++/// threaded separately by B14's dispatch). Deliberately distinct from any ++/// clap-side enum so the handler stays unaware of clap. ++pub enum OrgAddKind { ++ Login { ++ title: String, ++ username: Option, ++ url: Option, ++ password: Option, ++ }, ++ SecureNote { ++ title: String, ++ body: String, ++ }, ++ Identity { ++ title: String, ++ full_name: Option, ++ email: Option, ++ phone: Option, ++ }, ++} ++ ++/// Build a typed `Item` from a non-interactive `OrgAddKind` plus tags. ++fn build_org_item(kind: OrgAddKind, tags: Vec) -> Result { ++ use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore}; ++ use zeroize::Zeroizing; ++ ++ let mut item = match kind { ++ OrgAddKind::Login { title, username, url, password } => { ++ let parsed_url = match url { ++ Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?), ++ None => None, ++ }; ++ let password = password.map(Zeroizing::new); ++ Item::new(title, ItemCore::Login(LoginCore { ++ username, ++ password, ++ url: parsed_url, ++ totp: None, ++ })) ++ } ++ OrgAddKind::SecureNote { title, body } => { ++ Item::new(title, ItemCore::SecureNote(SecureNoteCore { ++ body: Zeroizing::new(body), ++ })) ++ } ++ OrgAddKind::Identity { title, full_name, email, phone } => { ++ Item::new(title, ItemCore::Identity(IdentityCore { ++ full_name, ++ address: None, ++ phone, ++ email, ++ date_of_birth: None, ++ })) ++ } ++ }; ++ item.tags = tags; ++ Ok(item) ++} ++ ++/// Insert-or-replace an `OrgManifestEntry` (keyed by item id), mirroring the ++/// personal-vault `Manifest::upsert`. The collection slug is stored in plaintext ++/// inside the encrypted manifest. ++fn upsert_org_entry( ++ manifest: &mut relicario_core::OrgManifest, ++ item: &Item, ++ collection: &str, ++) { ++ let entry = relicario_core::OrgManifestEntry { ++ id: item.id.clone(), ++ r#type: item.r#type, ++ title: item.title.clone(), ++ tags: item.tags.clone(), ++ modified: item.modified, ++ trashed_at: item.trashed_at, ++ collection: collection.to_string(), ++ }; ++ if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) { ++ *slot = entry; ++ } else { ++ manifest.entries.push(entry); ++ } ++} ++ ++/// Resolve a query (exact id, else case-insensitive title substring) against an ++/// already-grant-filtered manifest. ++fn resolve_org_query<'a>( ++ manifest: &'a relicario_core::OrgManifest, ++ query: &str, ++) -> Result<&'a relicario_core::OrgManifestEntry> { ++ if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) { ++ return Ok(entry); ++ } ++ let needle = query.to_lowercase(); ++ let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter() ++ .filter(|e| e.title.to_lowercase().contains(&needle)) ++ .collect(); ++ match hits.len() { ++ 0 => anyhow::bail!("no item matches `{query}`"), ++ 1 => Ok(hits[0]), ++ _ => { ++ let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect(); ++ anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", ")) ++ } ++ } ++} ++ ++// ── add ────────────────────────────────────────────────────────────────────── ++ ++/// `org add`: create a typed item in a collection the caller holds a grant for. ++pub fn run_org_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec) -> Result<()> { ++ let vault = crate::org_session::open_org_vault(Some(dir))?; ++ let caller = vault.current_member()?; ++ run_org_add_with(&vault, &caller, collection, kind, tags) ++} ++ ++fn run_org_add_with( ++ vault: &UnlockedOrgVault, ++ caller: &OrgMember, ++ collection: &str, ++ kind: OrgAddKind, ++ tags: Vec, ++) -> Result<()> { ++ // The slug must exist in collections.json… ++ let collections = vault.load_collections()?; ++ if !collections.contains_slug(collection) { ++ anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`"); ++ } ++ // …and the caller must hold a grant for it. ++ UnlockedOrgVault::ensure_grant(caller, collection)?; ++ ++ let item = build_org_item(kind, tags)?; ++ let item_rel = vault.save_item(collection, &item)?; ++ ++ // Upsert the manifest entry, then re-encrypt the manifest. ++ let mut manifest = vault.load_manifest()?; ++ upsert_org_entry(&mut manifest, &item, collection); ++ vault.save_manifest(&manifest)?; ++ ++ let subject = format!( ++ "org add: {} ({})", ++ crate::helpers::sanitize_for_commit(&item.title), ++ item.id.as_str() ++ ); ++ let commit_msg = format!( ++ "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}", ++ caller.display_name, ++ caller.member_id.as_str(), ++ collection, ++ item.id.as_str() ++ ); ++ crate::org_session::org_git_run( ++ &vault.root, ++ &["add", &item_rel, "manifest.enc"], ++ "org add: git add", ++ )?; ++ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?; ++ ++ println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection); ++ Ok(()) ++} ++ ++// ── list ───────────────────────────────────────────────────────────────────── ++ ++/// `org list`: list items in the caller's granted collections (filtered by ++/// `OrgManifest::filter_for_member`). `trashed` toggles between live + trashed. ++pub fn run_org_list(dir: &Path, trashed: bool) -> Result<()> { ++ let vault = crate::org_session::open_org_vault(Some(dir))?; ++ let caller = vault.current_member()?; ++ run_org_list_with(&vault, &caller, trashed) ++} ++ ++fn run_org_list_with(vault: &UnlockedOrgVault, caller: &OrgMember, trashed: bool) -> Result<()> { ++ let manifest = vault.load_manifest()?; ++ ++ // filter_for_member restricts to the caller's granted collections. ++ let visible = manifest.filter_for_member(caller); ++ ++ let mut entries: Vec<_> = visible.entries.iter() ++ .filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }) ++ .collect(); ++ entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); ++ ++ if entries.is_empty() { ++ eprintln!("(no items match)"); ++ return Ok(()); ++ } ++ ++ println!("{:<16} {:<14} {:<12} TITLE", "ID", "TYPE", "COLLECTION"); ++ for e in entries { ++ println!( ++ "{:<16} {:<14} {:<12} {}", ++ e.id.as_str(), ++ format!("{:?}", e.r#type), ++ e.collection, ++ e.title ++ ); ++ } ++ Ok(()) ++} ++ ++// ── get ────────────────────────────────────────────────────────────────────── ++ ++/// `org get`: print one item, masking secrets unless `show`. The query resolves ++/// over the caller-visible manifest only; the resolved collection's grant is ++/// re-checked (defense in depth). ++pub fn run_org_get(dir: &Path, query: &str, show: bool) -> Result<()> { ++ let vault = crate::org_session::open_org_vault(Some(dir))?; ++ let caller = vault.current_member()?; ++ run_org_get_with(&vault, &caller, query, show) ++} ++ ++fn run_org_get_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str, show: bool) -> Result<()> { ++ use zeroize::Zeroizing; ++ ++ let manifest = vault.load_manifest()?; ++ let visible = manifest.filter_for_member(caller); ++ ++ let entry = resolve_org_query(&visible, query)?; ++ UnlockedOrgVault::ensure_grant(caller, &entry.collection)?; ++ ++ let item = vault.load_item(&entry.collection, &entry.id)?; ++ ++ println!("ID: {}", item.id.as_str()); ++ println!("Title: {}", item.title); ++ println!("Type: {:?}", item.r#type); ++ println!("Collection: {}", entry.collection); ++ if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } ++ println!("Modified: {}", crate::helpers::iso8601(item.modified)); ++ if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } ++ println!(); ++ ++ let primary_secret: Option> = match &item.core { ++ ItemCore::Login(l) => { ++ if let Some(u) = &l.username { println!("Username: {u}"); } ++ if let Some(u) = &l.url { println!("URL: {u}"); } ++ l.password.clone() ++ } ++ ItemCore::SecureNote(n) => { ++ if show { println!("Body:\n{}", n.body.as_str()); } ++ else { println!("Body: ********"); } ++ None ++ } ++ ItemCore::Identity(i) => { ++ if let Some(v) = &i.full_name { println!("Name: {v}"); } ++ if let Some(v) = &i.email { println!("Email: {v}"); } ++ if let Some(v) = &i.phone { println!("Phone: {v}"); } ++ None ++ } ++ ItemCore::Card(c) => { ++ if let Some(h) = &c.holder { println!("Holder: {h}"); } ++ c.number.clone() ++ } ++ ItemCore::Key(k) => { ++ if let Some(l) = &k.label { println!("Label: {l}"); } ++ Some(k.key_material.clone()) ++ } ++ ItemCore::Document(d) => { ++ println!("Filename: {}", d.filename); ++ println!("MIME: {}", d.mime_type); ++ None ++ } ++ ItemCore::Totp(t) => { ++ if let Some(i) = &t.issuer { println!("Issuer: {i}"); } ++ if let Some(l) = &t.label { println!("Label: {l}"); } ++ None ++ } ++ }; ++ ++ if let Some(secret) = primary_secret { ++ if show { ++ println!("Secret: {}", secret.as_str()); ++ } else { ++ println!("Secret: ******** (use --show to reveal)"); ++ } ++ } ++ Ok(()) ++} ++ ++// ── edit ───────────────────────────────────────────────────────────────────── ++ ++/// `org edit`: flag-driven field update for login / secure-note / identity. ++/// Blank flags keep their current value. The blob is re-saved in place, the ++/// manifest upserted, and the commit carries `Relicario-Action: item-update`. ++#[allow(clippy::too_many_arguments)] ++pub fn run_org_edit( ++ dir: &Path, ++ query: &str, ++ title: Option, ++ username: Option, ++ url: Option, ++ password: Option, ++ body: Option, ++ email: Option, ++ phone: Option, ++ full_name: Option, ++) -> Result<()> { ++ let vault = crate::org_session::open_org_vault(Some(dir))?; ++ let caller = vault.current_member()?; ++ run_org_edit_with( ++ &vault, &caller, query, title, username, url, password, body, email, phone, full_name, ++ ) ++} ++ ++#[allow(clippy::too_many_arguments)] ++fn run_org_edit_with( ++ vault: &UnlockedOrgVault, ++ caller: &OrgMember, ++ query: &str, ++ title: Option, ++ username: Option, ++ url: Option, ++ password: Option, ++ body: Option, ++ email: Option, ++ phone: Option, ++ full_name: Option, ++) -> Result<()> { ++ use relicario_core::now_unix; ++ use zeroize::Zeroizing; ++ ++ let manifest = vault.load_manifest()?; ++ let visible = manifest.filter_for_member(caller); ++ let entry = resolve_org_query(&visible, query)?; ++ let collection = entry.collection.clone(); ++ let id = entry.id.clone(); ++ UnlockedOrgVault::ensure_grant(caller, &collection)?; ++ ++ let mut item = vault.load_item(&collection, &id)?; ++ ++ if let Some(t) = title { item.title = t; } ++ ++ match &mut item.core { ++ ItemCore::Login(l) => { ++ if let Some(u) = username { l.username = Some(u); } ++ if let Some(u) = url { ++ l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?); ++ } ++ if let Some(p) = password { l.password = Some(Zeroizing::new(p)); } ++ } ++ ItemCore::SecureNote(n) => { ++ if let Some(b) = body { n.body = Zeroizing::new(b); } ++ } ++ ItemCore::Identity(i) => { ++ if let Some(v) = full_name { i.full_name = Some(v); } ++ if let Some(v) = email { i.email = Some(v); } ++ if let Some(v) = phone { i.phone = Some(v); } ++ } ++ _ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"), ++ } ++ ++ item.modified = now_unix(); ++ let item_rel = vault.save_item(&collection, &item)?; ++ ++ let mut manifest = vault.load_manifest()?; ++ upsert_org_entry(&mut manifest, &item, &collection); ++ vault.save_manifest(&manifest)?; ++ ++ let subject = format!( ++ "org edit: {} ({})", ++ crate::helpers::sanitize_for_commit(&item.title), ++ item.id.as_str() ++ ); ++ let commit_msg = format!( ++ "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}", ++ caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() ++ ); ++ crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?; ++ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; ++ ++ println!("Updated {}", item.id.as_str()); ++ Ok(()) ++} ++ ++// ── trash lifecycle: rm / restore / purge ──────────────────────────────────── ++ ++/// Resolve a query to (collection, item) with grant enforcement. Shared by the ++/// trash-lifecycle commands. ++fn open_org_item( ++ vault: &UnlockedOrgVault, ++ caller: &OrgMember, ++ query: &str, ++) -> Result<(String, Item)> { ++ let manifest = vault.load_manifest()?; ++ let visible = manifest.filter_for_member(caller); ++ let entry = resolve_org_query(&visible, query)?; ++ let collection = entry.collection.clone(); ++ let id = entry.id.clone(); ++ UnlockedOrgVault::ensure_grant(caller, &collection)?; ++ let item = vault.load_item(&collection, &id)?; ++ Ok((collection, item)) ++} ++ ++/// `org rm`: soft-delete (sets `trashed_at`); reversible via `org restore`. ++pub fn run_org_rm(dir: &Path, query: &str) -> Result<()> { ++ let vault = crate::org_session::open_org_vault(Some(dir))?; ++ let caller = vault.current_member()?; ++ run_org_rm_with(&vault, &caller, query) ++} ++ ++fn run_org_rm_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> { ++ let (collection, mut item) = open_org_item(vault, caller, query)?; ++ ++ item.soft_delete(); ++ let item_rel = vault.save_item(&collection, &item)?; ++ let mut manifest = vault.load_manifest()?; ++ upsert_org_entry(&mut manifest, &item, &collection); ++ vault.save_manifest(&manifest)?; ++ ++ let commit_msg = format!( ++ "org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\nRelicario-Collection: {}\nRelicario-Item: {}", ++ crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(), ++ caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() ++ ); ++ crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?; ++ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?; ++ println!("Moved to trash: {}", item.title); ++ Ok(()) ++} ++ ++/// `org restore`: clear `trashed_at`, bringing the item back into the live list. ++pub fn run_org_restore(dir: &Path, query: &str) -> Result<()> { ++ let vault = crate::org_session::open_org_vault(Some(dir))?; ++ let caller = vault.current_member()?; ++ run_org_restore_with(&vault, &caller, query) ++} ++ ++fn run_org_restore_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> { ++ let (collection, mut item) = open_org_item(vault, caller, query)?; ++ ++ item.restore(); ++ let item_rel = vault.save_item(&collection, &item)?; ++ let mut manifest = vault.load_manifest()?; ++ upsert_org_entry(&mut manifest, &item, &collection); ++ vault.save_manifest(&manifest)?; ++ ++ let commit_msg = format!( ++ "org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}", ++ crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(), ++ caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() ++ ); ++ crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?; ++ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?; ++ println!("Restored: {}", item.title); ++ Ok(()) ++} ++ ++/// `org purge`: permanently delete the blob (git rm) and drop the manifest entry. ++pub fn run_org_purge(dir: &Path, query: &str) -> Result<()> { ++ let vault = crate::org_session::open_org_vault(Some(dir))?; ++ let caller = vault.current_member()?; ++ run_org_purge_with(&vault, &caller, query) ++} ++ ++fn run_org_purge_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> { ++ let (collection, item) = open_org_item(vault, caller, query)?; ++ let title = item.title.clone(); ++ let id = item.id.clone(); ++ ++ // Remove the blob from disk, drop the manifest entry, stage with git rm. ++ vault.remove_item(&collection, &id)?; ++ let mut manifest = vault.load_manifest()?; ++ manifest.entries.retain(|e| e.id != id); ++ vault.save_manifest(&manifest)?; ++ ++ let item_rel = format!("items/{}/{}.enc", collection, id.as_str()); ++ crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?; ++ crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?; ++ ++ let commit_msg = format!( ++ "org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}", ++ crate::helpers::sanitize_for_commit(&title), id.as_str(), ++ caller.display_name, caller.member_id.as_str(), collection, id.as_str() ++ ); ++ crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?; ++ println!("Purged: {title}"); ++ Ok(()) ++} ++ + #[cfg(test)] + mod tests { + use super::*; +@@ -386,3 +883,390 @@ mod tests { + assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string())); + } + } ++ ++// ═══════════ Item CRUD tests (B9-B13) ═══════════ ++// ++// `relicario-cli` is a binary-only crate, so integration tests in `tests/` ++// can only drive the compiled binary — and the item subcommands are not wired ++// into `Commands::Org` dispatch yet (that is B14). These in-process unit tests ++// therefore exercise the CRUD logic through the inner `*_with` helpers against a ++// directly-constructed `UnlockedOrgVault` over a real git repo, which needs no ++// device-fingerprint plumbing. The public `run_org_*` wrappers add only the ++// open-vault + resolve-caller preamble, which the `tests/org_*` integration ++// suites in the plan cover once B14 lands the CLI dispatch. ++#[cfg(test)] ++mod crud_tests { ++ use super::*; ++ use relicario_core::{ ++ encrypt_org_manifest, CollectionDef, ItemId, MemberId, OrgCollections, OrgManifest, ++ OrgMember, OrgRole, ++ }; ++ use std::path::Path; ++ use std::process::Command; ++ use tempfile::TempDir; ++ use zeroize::Zeroizing; ++ ++ /// A throwaway org vault: a real (unsigned-commit) git repo with the org ++ /// scaffold written and an `UnlockedOrgVault` holding a known key. ++ struct Fixture { ++ _dir: TempDir, ++ vault: UnlockedOrgVault, ++ } ++ ++ fn git(root: &Path, args: &[&str]) { ++ let out = Command::new("git").current_dir(root).args(args).output().unwrap(); ++ assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr)); ++ } ++ ++ impl Fixture { ++ fn new() -> Self { ++ let dir = TempDir::new().unwrap(); ++ let root = dir.path().to_path_buf(); ++ std::fs::create_dir_all(root.join("items")).unwrap(); ++ std::fs::create_dir_all(root.join("keys")).unwrap(); ++ ++ let org_key = Zeroizing::new([7u8; 32]); ++ ++ // Scaffold the non-encrypted control files. ++ std::fs::write( ++ root.join("collections.json"), ++ serde_json::to_string_pretty(&OrgCollections::new()).unwrap(), ++ ) ++ .unwrap(); ++ // Empty encrypted manifest. ++ let manifest = OrgManifest::new(); ++ std::fs::write( ++ root.join("manifest.enc"), ++ encrypt_org_manifest(&manifest, &org_key).unwrap(), ++ ) ++ .unwrap(); ++ ++ // A real git repo, but with signing disabled so commits succeed ++ // without a device key (signature verification is Dev-C's hook). ++ git(&root, &["init", "-q"]); ++ git(&root, &["config", "user.name", "Test"]); ++ git(&root, &["config", "user.email", "test@relicario.test"]); ++ git(&root, &["config", "commit.gpgsign", "false"]); ++ git(&root, &["add", "."]); ++ git(&root, &["commit", "-q", "-m", "scaffold"]); ++ ++ let vault = UnlockedOrgVault { root, org_key }; ++ Fixture { _dir: dir, vault } ++ } ++ ++ /// Add a collection to collections.json and return a member granted it. ++ fn with_collection(&self, slug: &str) -> OrgMember { ++ let mut collections = self.vault.load_collections().unwrap(); ++ collections.collections.push(CollectionDef { ++ slug: slug.to_string(), ++ display_name: slug.to_string(), ++ created_by: MemberId::new(), ++ created_at: 0, ++ }); ++ self.vault.save_collections(&collections).unwrap(); ++ self.member(vec![slug.to_string()]) ++ } ++ ++ fn member(&self, collections: Vec) -> OrgMember { ++ OrgMember { ++ member_id: MemberId("0123456789abcdef".into()), ++ display_name: "Alice".into(), ++ role: OrgRole::Owner, ++ ed25519_pubkey: "ssh-ed25519 AAAA fake".into(), ++ collections, ++ added_at: 0, ++ added_by: MemberId("0123456789abcdef".into()), ++ } ++ } ++ ++ fn head_body(&self) -> String { ++ let out = Command::new("git") ++ .current_dir(&self.vault.root) ++ .args(["log", "-1", "--format=%B"]) ++ .output() ++ .unwrap(); ++ String::from_utf8_lossy(&out.stdout).to_string() ++ } ++ ++ fn manifest_entry_for<'a>( ++ &self, ++ m: &'a OrgManifest, ++ title: &str, ++ ) -> Option<&'a relicario_core::OrgManifestEntry> { ++ m.entries.iter().find(|e| e.title == title) ++ } ++ } ++ ++ fn login(title: &str, user: &str, pw: &str) -> OrgAddKind { ++ OrgAddKind::Login { ++ title: title.into(), ++ username: Some(user.into()), ++ url: Some("https://example.com".into()), ++ password: Some(pw.into()), ++ } ++ } ++ ++ // ── B10: add ────────────────────────────────────────────────────────────── ++ ++ #[test] ++ fn add_writes_collection_scoped_blob_and_manifest_and_trailers() { ++ let f = Fixture::new(); ++ let caller = f.with_collection("prod"); ++ ++ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![]) ++ .unwrap(); ++ ++ // Blob lives under items/prod/, not flat items/. ++ let prod_dir = f.vault.root.join("items").join("prod"); ++ let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect(); ++ assert_eq!(blobs.len(), 1, "expected exactly one blob under items/prod/"); ++ assert!(!f.vault.root.join("items").join("GitHub.enc").exists()); ++ ++ // Manifest entry recorded with the collection. ++ let manifest = f.vault.load_manifest().unwrap(); ++ let entry = f.manifest_entry_for(&manifest, "GitHub").expect("manifest entry"); ++ assert_eq!(entry.collection, "prod"); ++ ++ // Commit trailers. ++ let body = f.head_body(); ++ assert!(body.contains("Relicario-Action: item-create"), "body: {body}"); ++ assert!(body.contains("Relicario-Collection: prod"), "body: {body}"); ++ assert!(body.contains(&format!("Relicario-Item: {}", entry.id.as_str())), "body: {body}"); ++ assert!(body.contains("Relicario-Actor: Alice 0123456789abcdef"), "body: {body}"); ++ } ++ ++ #[test] ++ fn add_secure_note_and_identity_round_trip() { ++ let f = Fixture::new(); ++ let caller = f.with_collection("prod"); ++ ++ run_org_add_with( ++ &f.vault, ++ &caller, ++ "prod", ++ OrgAddKind::SecureNote { title: "Notes".into(), body: "secret-body".into() }, ++ vec!["tag1".into()], ++ ) ++ .unwrap(); ++ run_org_add_with( ++ &f.vault, ++ &caller, ++ "prod", ++ OrgAddKind::Identity { ++ title: "Me".into(), ++ full_name: Some("Alice Anderson".into()), ++ email: Some("a@example.com".into()), ++ phone: None, ++ }, ++ vec![], ++ ) ++ .unwrap(); ++ ++ let manifest = f.vault.load_manifest().unwrap(); ++ assert_eq!(manifest.entries.len(), 2); ++ let note = f.manifest_entry_for(&manifest, "Notes").unwrap(); ++ assert_eq!(note.tags, vec!["tag1".to_string()]); ++ let note_item = f.vault.load_item("prod", ¬e.id).unwrap(); ++ match ¬e_item.core { ++ ItemCore::SecureNote(n) => assert_eq!(n.body.as_str(), "secret-body"), ++ _ => panic!("expected secure note"), ++ } ++ } ++ ++ #[test] ++ fn add_rejects_ungranted_collection() { ++ let f = Fixture::new(); ++ // Collection exists, but the caller holds no grant for it. ++ let _ = f.with_collection("secret"); ++ let caller = f.member(vec![]); // no grants ++ ++ let err = run_org_add_with(&f.vault, &caller, "secret", login("X", "u", "p"), vec![]) ++ .unwrap_err(); ++ let msg = format!("{err:#}"); ++ assert!(msg.contains("access denied") || msg.contains("grant"), "msg: {msg}"); ++ } ++ ++ #[test] ++ fn add_rejects_unknown_collection() { ++ let f = Fixture::new(); ++ let caller = f.member(vec!["ghost".into()]); // grant for a slug that doesn't exist ++ ++ let err = run_org_add_with(&f.vault, &caller, "ghost", login("X", "u", "p"), vec![]) ++ .unwrap_err(); ++ let msg = format!("{err:#}"); ++ assert!(msg.contains("does not exist") || msg.contains("ghost"), "msg: {msg}"); ++ } ++ ++ // ── B11: get + list ─────────────────────────────────────────────────────── ++ ++ #[test] ++ fn list_filters_to_granted_collections() { ++ let f = Fixture::new(); ++ // Two collections exist; caller is granted only `prod`. ++ let _ = f.with_collection("prod"); ++ let _ = f.with_collection("secret"); ++ let prod_caller = f.member(vec!["prod".into()]); ++ let secret_caller = f.member(vec!["secret".into()]); ++ ++ run_org_add_with(&f.vault, &prod_caller, "prod", login("InProd", "u", "p"), vec![]).unwrap(); ++ run_org_add_with(&f.vault, &secret_caller, "secret", login("InSecret", "u", "p"), vec![]) ++ .unwrap(); ++ ++ // The prod caller's visible manifest excludes the secret entry. ++ let manifest = f.vault.load_manifest().unwrap(); ++ let visible = manifest.filter_for_member(&prod_caller); ++ let titles: Vec<&str> = visible.entries.iter().map(|e| e.title.as_str()).collect(); ++ assert!(titles.contains(&"InProd")); ++ assert!(!titles.contains(&"InSecret"), "leaked ungranted entry: {titles:?}"); ++ ++ // run_org_list_with returns Ok and prints only granted entries. ++ run_org_list_with(&f.vault, &prod_caller, false).unwrap(); ++ } ++ ++ #[test] ++ fn get_resolves_by_id_and_title_substring() { ++ let f = Fixture::new(); ++ let caller = f.with_collection("prod"); ++ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![]) ++ .unwrap(); ++ ++ let manifest = f.vault.load_manifest().unwrap(); ++ let id = manifest.entries[0].id.as_str().to_string(); ++ ++ // exact id, case-insensitive substring, masked default + --show all OK. ++ run_org_get_with(&f.vault, &caller, &id, false).unwrap(); ++ run_org_get_with(&f.vault, &caller, "github", false).unwrap(); ++ run_org_get_with(&f.vault, &caller, "GitHub", true).unwrap(); ++ } ++ ++ #[test] ++ fn get_unknown_query_errors() { ++ let f = Fixture::new(); ++ let caller = f.with_collection("prod"); ++ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![]) ++ .unwrap(); ++ let err = run_org_get_with(&f.vault, &caller, "nope", false).unwrap_err(); ++ assert!(format!("{err:#}").contains("no item matches")); ++ } ++ ++ #[test] ++ fn resolve_org_query_reports_ambiguity() { ++ let mut manifest = OrgManifest::new(); ++ for title in ["Mail Personal", "Mail Work"] { ++ manifest.entries.push(relicario_core::OrgManifestEntry { ++ id: ItemId::new(), ++ r#type: relicario_core::ItemType::Login, ++ title: title.into(), ++ tags: vec![], ++ modified: 0, ++ trashed_at: None, ++ collection: "prod".into(), ++ }); ++ } ++ let err = resolve_org_query(&manifest, "mail").unwrap_err(); ++ assert!(format!("{err:#}").contains("ambiguous"), "{err:#}"); ++ } ++ ++ // ── B12: edit ───────────────────────────────────────────────────────────── ++ ++ #[test] ++ fn edit_updates_login_field_and_writes_update_trailer() { ++ let f = Fixture::new(); ++ let caller = f.with_collection("prod"); ++ run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap(); ++ ++ run_org_edit_with( ++ &f.vault, &caller, "Mail", ++ None, Some("new-user".into()), None, None, None, None, None, None, ++ ) ++ .unwrap(); ++ ++ // The blob now carries the new username. ++ let manifest = f.vault.load_manifest().unwrap(); ++ let entry = f.manifest_entry_for(&manifest, "Mail").unwrap(); ++ let item = f.vault.load_item("prod", &entry.id).unwrap(); ++ match &item.core { ++ ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("new-user")), ++ _ => panic!("expected login"), ++ } ++ ++ let body = f.head_body(); ++ assert!(body.contains("Relicario-Action: item-update"), "body: {body}"); ++ assert!(body.contains("Relicario-Collection: prod"), "body: {body}"); ++ } ++ ++ #[test] ++ fn edit_can_retitle_and_keeps_unset_fields() { ++ let f = Fixture::new(); ++ let caller = f.with_collection("prod"); ++ run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap(); ++ ++ run_org_edit_with( ++ &f.vault, &caller, "Mail", ++ Some("Webmail".into()), None, None, None, None, None, None, None, ++ ) ++ .unwrap(); ++ ++ let manifest = f.vault.load_manifest().unwrap(); ++ assert!(f.manifest_entry_for(&manifest, "Webmail").is_some()); ++ let entry = f.manifest_entry_for(&manifest, "Webmail").unwrap(); ++ let item = f.vault.load_item("prod", &entry.id).unwrap(); ++ match &item.core { ++ // username untouched (we passed None), password untouched. ++ ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("old")), ++ _ => panic!("expected login"), ++ } ++ } ++ ++ // ── B13: rm / restore / purge ───────────────────────────────────────────── ++ ++ #[test] ++ fn rm_restore_purge_cycle() { ++ let f = Fixture::new(); ++ let caller = f.with_collection("prod"); ++ run_org_add_with( ++ &f.vault, ++ &caller, ++ "prod", ++ OrgAddKind::SecureNote { title: "Recovery".into(), body: "codes-here".into() }, ++ vec![], ++ ) ++ .unwrap(); ++ ++ // rm → trashed_at set, item drops out of the live list, shows in --trashed. ++ run_org_rm_with(&f.vault, &caller, "Recovery").unwrap(); ++ let manifest = f.vault.load_manifest().unwrap(); ++ let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap(); ++ assert!(entry.trashed_at.is_some(), "rm should set trashed_at"); ++ assert!(f.head_body().contains("Relicario-Action: item-delete")); ++ ++ // restore → trashed_at cleared. ++ run_org_restore_with(&f.vault, &caller, "Recovery").unwrap(); ++ let manifest = f.vault.load_manifest().unwrap(); ++ let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap(); ++ assert!(entry.trashed_at.is_none(), "restore should clear trashed_at"); ++ assert!(f.head_body().contains("Relicario-Action: item-restore")); ++ ++ // purge → blob gone from disk, manifest entry dropped, purge trailer. ++ run_org_purge_with(&f.vault, &caller, "Recovery").unwrap(); ++ let prod_dir = f.vault.root.join("items").join("prod"); ++ let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0); ++ assert_eq!(count, 0, "blob not purged from items/prod/"); ++ let manifest = f.vault.load_manifest().unwrap(); ++ assert!(f.manifest_entry_for(&manifest, "Recovery").is_none(), "manifest entry not dropped"); ++ assert!(f.head_body().contains("Relicario-Action: item-purge")); ++ } ++ ++ #[test] ++ fn rm_enforces_grant_via_visible_manifest() { ++ let f = Fixture::new(); ++ // owner adds into prod ++ let owner = f.with_collection("prod"); ++ run_org_add_with(&f.vault, &owner, "prod", login("Secret", "u", "p"), vec![]).unwrap(); ++ ++ // a caller with no grant cannot even resolve the item (filtered out). ++ let outsider = f.member(vec![]); ++ let err = run_org_rm_with(&f.vault, &outsider, "Secret").unwrap_err(); ++ assert!(format!("{err:#}").contains("no item matches"), "{err:#}"); ++ } ++} diff --git a/docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-2-statusaudit.uncommitted.patch b/docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-2-statusaudit.uncommitted.patch new file mode 100644 index 0000000..be27fc1 --- /dev/null +++ b/docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-2-statusaudit.uncommitted.patch @@ -0,0 +1,521 @@ +diff --git a/Cargo.lock b/Cargo.lock +index ffaf13f..5b9a869 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -2172,6 +2172,7 @@ dependencies = [ + "predicates", + "qrcode", + "rand", ++ "regex", + "relicario-core", + "reqwest", + "rpassword", +diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml +index db05181..928004c 100644 +--- a/crates/relicario-cli/Cargo.toml ++++ b/crates/relicario-cli/Cargo.toml +@@ -31,10 +31,11 @@ rqrr = "0.7" + reqwest = { version = "0.12", features = ["blocking", "json"] } + qrcode = { version = "0.14", features = ["svg"] } + ssh-key = { version = "0.6", features = ["ed25519", "std"] } ++regex = "1" ++tempfile = "3" + + [dev-dependencies] + assert_cmd = "2" + predicates = "3" +-tempfile = "3" + serde_json = "1" + ed25519-dalek = "2" +diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs +index b0f1bf8..799b14b 100644 +--- a/crates/relicario-cli/src/commands/org.rs ++++ b/crates/relicario-cli/src/commands/org.rs +@@ -329,6 +329,285 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { + } + } + ++// ═══════════ Status / Audit (B8) ═══════════ ++ ++/// `org status`: print the org's members + collections with no decryption. Reads ++/// the three plaintext metadata files (org.json, members.json, collections.json) ++/// directly — the manifest stays encrypted and is never touched. ++pub fn run_org_status(dir: &Path) -> Result<()> { ++ let root = crate::org_session::org_dir(Some(dir))?; ++ ++ let meta: relicario_core::OrgMeta = { ++ let s = fs::read_to_string(root.join("org.json")).context("read org.json")?; ++ serde_json::from_str(&s).context("parse org.json")? ++ }; ++ let members: OrgMembers = { ++ let s = fs::read_to_string(root.join("members.json")).context("read members.json")?; ++ serde_json::from_str(&s).context("parse members.json")? ++ }; ++ let collections: OrgCollections = { ++ let s = fs::read_to_string(root.join("collections.json")) ++ .context("read collections.json")?; ++ serde_json::from_str(&s).context("parse collections.json")? ++ }; ++ ++ println!("Org: {} ({})", meta.display_name, meta.org_id.as_str()); ++ println!(); ++ println!("Members ({}):", members.members.len()); ++ for m in &members.members { ++ let colls = if m.collections.is_empty() { ++ "(no collections)".to_string() ++ } else { ++ m.collections.join(", ") ++ }; ++ println!( ++ " {:?} {} {} [{}]", ++ m.role, ++ m.member_id.as_str(), ++ m.display_name, ++ colls ++ ); ++ } ++ println!(); ++ println!("Collections ({}):", collections.collections.len()); ++ for c in &collections.collections { ++ println!(" {} — {}", c.slug, c.display_name); ++ } ++ Ok(()) ++} ++ ++/// One audited org-vault commit, attributed to a VERIFIED git signer. ++#[derive(Debug, serde::Serialize)] ++pub struct AuditEvent { ++ pub commit: String, ++ pub timestamp: String, ++ /// Actor as resolved from the VERIFIED signing key (authoritative). ++ pub actor_name: Option, ++ pub actor_id: Option, ++ /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking). ++ pub trailer_actor_id: Option, ++ pub action: Option, ++ pub collection: Option, ++ pub item_id: Option, ++ pub device_id: Option, ++ /// True when the trailer's claimed actor disagrees with the verified signer, ++ /// or when no current member matches the signing key. ++ pub tampered: bool, ++} ++ ++/// Parse a commit's `Relicario-*` trailer block into an `AuditEvent`. The actor ++/// id captured here is the trailer's CLAIM (`trailer_actor_id`) — the ++/// authoritative `actor_id` is resolved later from the verified signature. ++fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent { ++ let mut ev = AuditEvent { ++ commit: commit.to_string(), ++ timestamp: timestamp.to_string(), ++ actor_name: None, ++ actor_id: None, ++ trailer_actor_id: None, ++ action: None, ++ collection: None, ++ item_id: None, ++ device_id: None, ++ tampered: false, ++ }; ++ for line in trailers.lines() { ++ let line = line.trim(); ++ if let Some(rest) = line.strip_prefix("Relicario-Actor:") { ++ // Contract format: " " (member_id is the last token). ++ let rest = rest.trim(); ++ if let Some((_name, id)) = rest.rsplit_once(' ') { ++ ev.trailer_actor_id = Some(id.trim().to_string()); ++ } else if !rest.is_empty() { ++ ev.trailer_actor_id = Some(rest.to_string()); ++ } ++ } else if let Some(v) = line.strip_prefix("Relicario-Action:") { ++ ev.action = Some(v.trim().to_string()); ++ } else if let Some(v) = line.strip_prefix("Relicario-Collection:") { ++ ev.collection = Some(v.trim().to_string()); ++ } else if let Some(v) = line.strip_prefix("Relicario-Item:") { ++ ev.item_id = Some(v.trim().to_string()); ++ } else if let Some(v) = line.strip_prefix("Relicario-Device:") { ++ ev.device_id = Some(v.trim().to_string()); ++ } ++ } ++ ev ++} ++ ++/// Resolve a commit's SSH signature fingerprint to a current member, mirroring ++/// the pre-receive hook: build an allowed_signers from members.json, inject it ++/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from ++/// stderr. Returns None if the commit is unsigned or the signer is not a member. ++fn resolve_signer<'m>( ++ root: &Path, ++ commit: &str, ++ members: &'m relicario_core::OrgMembers, ++) -> Option<&'m relicario_core::OrgMember> { ++ use std::io::Write; ++ let mut tmp = tempfile::NamedTempFile::new().ok()?; ++ for m in &members.members { ++ let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim()); ++ } ++ let allowed_path = tmp.path(); ++ ++ let output = std::process::Command::new("git") ++ .current_dir(root) ++ .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() ++ .ok()?; ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ ++ let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?; ++ let fp = re.captures(&stderr)?.get(1)?.as_str().to_string(); ++ ++ members.members.iter().find(|m| { ++ relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str()) ++ }) ++} ++ ++/// `org audit`: parse `git log`, resolve each commit's VERIFIED signer to a ++/// member and report THAT as the actor (trailers are advisory), flag ++/// trailer/signer mismatch as `TAMPERED`, and frame records with `%x1e`/`%x1f` ++/// (so multi-line trailer values cannot misalign records) using the committer ++/// date (`%cI`). ++pub fn run_org_audit( ++ dir: &Path, ++ since: Option<&str>, ++ member_filter: Option<&str>, ++ collection_filter: Option<&str>, ++ action_filter: Option<&str>, ++ format: &str, ++) -> Result<()> { ++ // Spec surface is `--format ` (default table). Accept only those. ++ let json = match format { ++ "json" => true, ++ "table" => false, ++ other => anyhow::bail!("unknown --format `{other}` — use table or json"), ++ }; ++ let root = crate::org_session::org_dir(Some(dir))?; ++ ++ // members.json — needed to resolve each commit's verified signer to a member. ++ let members: relicario_core::OrgMembers = { ++ let s = fs::read_to_string(root.join("members.json")).context("read members.json")?; ++ serde_json::from_str(&s).context("parse members.json")? ++ }; ++ ++ // git log framed with a record separator (%x1e, U+001E) PER COMMIT and a ++ // field separator (%x1f, U+001F) between fields, so multi-line trailer ++ // values cannot misalign record boundaries. Committer date (%cI), not ++ // author date: it is what revocation/audit is anchored to. ++ let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)"; ++ let mut args: Vec = vec!["log".into(), format!("--format={fmt}")]; ++ if let Some(s) = since { ++ args.push(format!("--since={s}")); ++ } ++ ++ let output = std::process::Command::new("git") ++ .current_dir(&root) ++ .args(&args) ++ .output() ++ .context("git log")?; ++ let log = String::from_utf8_lossy(&output.stdout); ++ ++ let events = parse_audit_log(&root, &log, &members, member_filter, collection_filter, action_filter); ++ ++ if json { ++ println!("{}", serde_json::to_string_pretty(&events)?); ++ } else { ++ println!( ++ "{:<44} {:<26} {:<20} {:<18} {}", ++ "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG" ++ ); ++ for ev in &events { ++ println!( ++ "{:<44} {:<26} {:<20} {:<18} {}", ++ ev.commit, ++ ev.timestamp, ++ ev.action.as_deref().unwrap_or("-"), ++ ev.actor_name.as_deref().unwrap_or(""), ++ if ev.tampered { "TAMPERED" } else { "" }, ++ ); ++ } ++ } ++ Ok(()) ++} ++ ++/// Frame a raw `git log` body (records split on `%x1e`, fields on `%x1f`) into ++/// attributed `AuditEvent`s. Each commit's VERIFIED signer is resolved via ++/// `resolve_signer` and reported as the authoritative actor; trailer/signer ++/// disagreement (or no matching member) sets the `tampered` flag. Filters apply ++/// to the VERIFIED actor id, not the spoofable trailer. Split out from ++/// `run_org_audit` so it can be unit-tested over a real signed repo. ++fn parse_audit_log( ++ root: &Path, ++ log: &str, ++ members: &relicario_core::OrgMembers, ++ member_filter: Option<&str>, ++ collection_filter: Option<&str>, ++ action_filter: Option<&str>, ++) -> Vec { ++ let mut events: Vec = Vec::new(); ++ for record in log.split('\u{1e}') { ++ let record = record.trim_start_matches('\n'); ++ if record.trim().is_empty() { ++ continue; ++ } ++ let mut fields = record.splitn(3, '\u{1f}'); ++ let commit = fields.next().unwrap_or("").trim(); ++ let ts = fields.next().unwrap_or("").trim(); ++ let trailers = fields.next().unwrap_or(""); ++ if commit.is_empty() { ++ continue; ++ } ++ ++ let mut ev = parse_trailer_block(commit, ts, trailers); ++ if ev.action.is_none() { ++ continue; // not an org commit ++ } ++ ++ // Resolve the VERIFIED signer and attribute it as the authoritative actor. ++ match resolve_signer(root, commit, members) { ++ Some(m) => { ++ ev.actor_name = Some(m.display_name.clone()); ++ ev.actor_id = Some(m.member_id.as_str().to_string()); ++ // Tampered if the trailer claims a different actor than the signer. ++ if let Some(claimed) = ev.trailer_actor_id.as_deref() { ++ if claimed != m.member_id.as_str() { ++ ev.tampered = true; ++ } ++ } ++ } ++ None => { ++ // No current member matched the signature -> cannot trust the ++ // trailer's claimed actor. ++ ev.tampered = true; ++ } ++ } ++ ++ if let Some(mid) = member_filter { ++ // Filter on the VERIFIED actor id, not the spoofable trailer. ++ if ev.actor_id.as_deref() != Some(mid) { ++ continue; ++ } ++ } ++ if let Some(col) = collection_filter { ++ if ev.collection.as_deref() != Some(col) { ++ continue; ++ } ++ } ++ if let Some(act) = action_filter { ++ if ev.action.as_deref() != Some(act) { ++ continue; ++ } ++ } ++ events.push(ev); ++ } ++ events ++} ++ + #[cfg(test)] + mod tests { + use super::*; +@@ -385,4 +664,201 @@ mod tests { + assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); + assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string())); + } ++ ++ // ───── Status / Audit (B8) ───── ++ ++ #[test] ++ fn parse_trailers_extracts_relicario_fields() { ++ // Contract trailer shape: "Relicario-Actor: ". ++ let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n"; ++ let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw); ++ assert_eq!(event.action.as_deref(), Some("item-create")); ++ assert_eq!(event.collection.as_deref(), Some("prod")); ++ // The verified actor_id is resolved later from the signature, not the trailer; ++ // the trailer only populates trailer_actor_id here. ++ assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2")); ++ assert_eq!(event.actor_id, None); ++ assert!(!event.tampered); ++ } ++ ++ #[test] ++ fn parse_trailers_captures_item_and_device() { ++ let raw = "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Item: 0123456789abcdef\nRelicario-Device: laptop\n"; ++ let ev = parse_trailer_block("def456", "2026-06-06T13:00:00+00:00", raw); ++ assert_eq!(ev.action.as_deref(), Some("item-update")); ++ assert_eq!(ev.item_id.as_deref(), Some("0123456789abcdef")); ++ assert_eq!(ev.device_id.as_deref(), Some("laptop")); ++ assert_eq!(ev.trailer_actor_id.as_deref(), Some("feedfacefeedface")); ++ } ++ ++ #[test] ++ fn parse_trailers_single_token_actor_falls_back_to_whole_value() { ++ // No space => the whole value is treated as the member id. ++ let raw = "Relicario-Actor: lonelytoken00000\nRelicario-Action: org-init\n"; ++ let ev = parse_trailer_block("c0ffee", "2026-06-06T14:00:00+00:00", raw); ++ assert_eq!(ev.trailer_actor_id.as_deref(), Some("lonelytoken00000")); ++ assert_eq!(ev.action.as_deref(), Some("org-init")); ++ } ++ ++ #[test] ++ fn parse_trailers_non_org_commit_has_no_action() { ++ // A commit with no Relicario-* trailers parses to an event with no action, ++ // which run_org_audit skips. ++ let ev = parse_trailer_block("beef", "2026-06-06T15:00:00+00:00", ""); ++ assert!(ev.action.is_none()); ++ } ++} ++ ++#[cfg(test)] ++mod audit_log_tests { ++ //! Record-framing + filter tests for `parse_audit_log` against a synthetic ++ //! `git log` body (no real repo / signatures needed: members.json is empty so ++ //! `resolve_signer` always returns None and every org commit is flagged ++ //! TAMPERED — which is exactly the "signer is not a current member" path). ++ use super::*; ++ use relicario_core::OrgMembers; ++ ++ /// Build one framed record: leading %x1e, then commit %x1f ts %x1f trailers. ++ fn record(commit: &str, ts: &str, trailers: &str) -> String { ++ format!("\u{1e}{commit}\u{1f}{ts}\u{1f}{trailers}") ++ } ++ ++ #[test] ++ fn parse_audit_log_frames_records_and_flags_unverified() { ++ let members = OrgMembers::new(); // no members => no signer can resolve ++ let log = format!( ++ "{}{}", ++ record( ++ "1111111111111111111111111111111111111111", ++ "2026-06-06T12:00:00+00:00", ++ "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n", ++ ), ++ record( ++ "2222222222222222222222222222222222222222", ++ "2026-06-06T13:00:00+00:00", ++ "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Collection: dev\n", ++ ), ++ ); ++ // root path is unused once resolve_signer short-circuits on empty members, ++ // but verify-commit will run; point it at a tempdir to be safe. ++ let tmp = tempfile::tempdir().unwrap(); ++ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None); ++ assert_eq!(events.len(), 2); ++ // Leading %x1e produced an empty leading split element that was filtered. ++ assert_eq!(events[0].commit, "1111111111111111111111111111111111111111"); ++ assert_eq!(events[0].action.as_deref(), Some("item-create")); ++ assert_eq!(events[0].collection.as_deref(), Some("prod")); ++ // No member matched the (absent) signature => TAMPERED, no verified actor. ++ assert!(events[0].tampered); ++ assert_eq!(events[0].actor_name, None); ++ assert_eq!(events[0].actor_id, None); ++ // Trailer claim is preserved for forensic comparison. ++ assert_eq!(events[0].trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2")); ++ } ++ ++ #[test] ++ fn parse_audit_log_skips_non_org_commits() { ++ let members = OrgMembers::new(); ++ let log = format!( ++ "{}{}", ++ // A non-org commit: no Relicario-Action trailer. ++ record("3333", "2026-06-06T10:00:00+00:00", "Some-Other: trailer\n"), ++ record( ++ "4444", ++ "2026-06-06T11:00:00+00:00", ++ "Relicario-Action: org-init\nRelicario-Actor: alice a1b2c3d4e5f6a1b2\n", ++ ), ++ ); ++ let tmp = tempfile::tempdir().unwrap(); ++ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None); ++ assert_eq!(events.len(), 1); ++ assert_eq!(events[0].commit, "4444"); ++ assert_eq!(events[0].action.as_deref(), Some("org-init")); ++ } ++ ++ #[test] ++ fn parse_audit_log_multiline_trailer_value_does_not_misalign() { ++ // A multi-line trailer value must not break record framing: only %x1e ++ // ends a record, not a newline inside the trailer block. ++ let members = OrgMembers::new(); ++ let log = format!( ++ "{}{}", ++ record( ++ "5555", ++ "2026-06-06T09:00:00+00:00", ++ "Relicario-Action: item-create\nRelicario-Actor: carol cafecafecafecafe\nRelicario-Collection: prod\n", ++ ), ++ record( ++ "6666", ++ "2026-06-06T09:30:00+00:00", ++ "Relicario-Action: item-delete\nRelicario-Actor: dave deaddeaddeaddead\nRelicario-Collection: dev\n", ++ ), ++ ); ++ let tmp = tempfile::tempdir().unwrap(); ++ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None); ++ assert_eq!(events.len(), 2); ++ assert_eq!(events[0].commit, "5555"); ++ assert_eq!(events[1].commit, "6666"); ++ assert_eq!(events[1].action.as_deref(), Some("item-delete")); ++ } ++ ++ #[test] ++ fn parse_audit_log_collection_and_action_filters_apply() { ++ let members = OrgMembers::new(); ++ let log = format!( ++ "{}{}{}", ++ record( ++ "7777", ++ "2026-06-06T08:00:00+00:00", ++ "Relicario-Action: item-create\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n", ++ ), ++ record( ++ "8888", ++ "2026-06-06T08:10:00+00:00", ++ "Relicario-Action: item-update\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n", ++ ), ++ record( ++ "9999", ++ "2026-06-06T08:20:00+00:00", ++ "Relicario-Action: item-create\nRelicario-Collection: dev\nRelicario-Actor: a aaaa000000000000\n", ++ ), ++ ); ++ let tmp = tempfile::tempdir().unwrap(); ++ ++ // Collection filter: only prod commits survive. ++ let prod = parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), None); ++ assert_eq!(prod.len(), 2); ++ assert!(prod.iter().all(|e| e.collection.as_deref() == Some("prod"))); ++ ++ // Action filter: only item-create commits survive. ++ let creates = parse_audit_log(tmp.path(), &log, &members, None, None, Some("item-create")); ++ assert_eq!(creates.len(), 2); ++ assert!(creates.iter().all(|e| e.action.as_deref() == Some("item-create"))); ++ ++ // Combined: item-create AND prod => just commit 7777. ++ let combined = ++ parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), Some("item-create")); ++ assert_eq!(combined.len(), 1); ++ assert_eq!(combined[0].commit, "7777"); ++ } ++ ++ #[test] ++ fn parse_audit_log_member_filter_uses_verified_actor_not_trailer() { ++ // With no resolvable signer, actor_id is None, so a member filter naming ++ // the TRAILER's claimed id must NOT match — the filter is on the verified ++ // actor, which is the whole point of TAMPERED attribution. ++ let members = OrgMembers::new(); ++ let log = record( ++ "aaaa", ++ "2026-06-06T07:00:00+00:00", ++ "Relicario-Action: item-create\nRelicario-Actor: mallory deadbeefdeadbeef\n", ++ ); ++ let tmp = tempfile::tempdir().unwrap(); ++ let filtered = ++ parse_audit_log(tmp.path(), &log, &members, Some("deadbeefdeadbeef"), None, None); ++ assert!( ++ filtered.is_empty(), ++ "member filter must match the verified actor id, never the spoofable trailer" ++ ); ++ } + } diff --git a/docs/superpowers/salvage/2026-06-20-org-vault-tail/org_audit.f3e-2.rs b/docs/superpowers/salvage/2026-06-20-org-vault-tail/org_audit.f3e-2.rs new file mode 100644 index 0000000..3d6af97 --- /dev/null +++ b/docs/superpowers/salvage/2026-06-20-org-vault-tail/org_audit.f3e-2.rs @@ -0,0 +1,156 @@ +//! B8 `org audit` verified-signer attribution — integration coverage. +//! +//! The audit logic (`resolve_signer`, `parse_audit_log`, `run_org_audit`) lives +//! in the bin crate's private `commands::org` module and the CLI dispatch is not +//! wired until B14, so we cannot drive `org audit` through the binary yet. What +//! we CAN do is build a real signed org vault via `org init` and assert that the +//! exact verification mechanism `resolve_signer` uses — a temp `allowed_signers` +//! prefixed `relicario `, injected via `GIT_CONFIG_*`, then +//! `git verify-commit --raw`, then the `key (SHA256:...)` regex over stderr — +//! resolves the genesis commit's signature to the seeded member's fingerprint. +//! +//! This pins the security-critical half of B8 (attribute to the VERIFIED signer, +//! mirroring the pre-receive hook) against a genuine SSH signature rather than +//! the synthetic-log unit tests, which only cover the "no member matched -> +//! TAMPERED" fallback. + +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::Command; +use tempfile::{NamedTempFile, TempDir}; + +fn relicario_with_git_identity(config_home: &Path, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_relicario")) + .env("XDG_CONFIG_HOME", config_home) + .env("HOME", config_home) + .env("GIT_AUTHOR_NAME", "Test Device") + .env("GIT_AUTHOR_EMAIL", "test@relicario.test") + .env("GIT_COMMITTER_NAME", "Test Device") + .env("GIT_COMMITTER_EMAIL", "test@relicario.test") + .args(args) + .output() + .expect("run relicario") +} + +/// Lay out a device keypair under `/relicario/devices//` and +/// mark it current. Mirrors `org_init_signing::seed_device`. Returns the OpenSSH +/// public key string. +fn seed_device(config_home: &Path, name: &str) -> String { + let (priv_openssh, pub_openssh) = + relicario_core::device::generate_keypair().expect("generate_keypair"); + + let dev_dir = config_home.join("relicario").join("devices").join(name); + fs::create_dir_all(&dev_dir).expect("create device dir"); + let signing_key_path = dev_dir.join("signing.key"); + fs::write(&signing_key_path, priv_openssh.as_str()).expect("write signing.key"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&signing_key_path, fs::Permissions::from_mode(0o600)) + .expect("chmod signing.key"); + } + fs::write(dev_dir.join("signing.pub"), &pub_openssh).expect("write signing.pub"); + fs::write(dev_dir.join("deploy.key"), "").expect("write stub deploy.key"); + fs::write(dev_dir.join("deploy.pub"), "").expect("write stub deploy.pub"); + + let devices_dir = config_home.join("relicario").join("devices"); + fs::write(devices_dir.join("current"), format!("{name}\n")).expect("write current"); + + pub_openssh +} + +/// Replicate `commands::org::resolve_signer`'s verification: build an +/// allowed_signers file from the given pubkeys (prefixed `relicario `), inject it +/// via GIT_CONFIG_*, run `git verify-commit --raw`, and parse the SHA256 key +/// fingerprint from stderr. +fn resolve_signer_fp(org_root: &Path, commit: &str, pubkeys: &[&str]) -> Option { + let mut tmp = NamedTempFile::new().ok()?; + for pk in pubkeys { + writeln!(tmp, "relicario {}", pk.trim()).ok()?; + } + let allowed_path = tmp.path(); + + let output = Command::new("git") + .current_dir(org_root) + .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() + .ok()?; + + // The clean exit IS the gate (matches the hook): a non-member signature fails. + if !output.status.success() { + return None; + } + let stderr = String::from_utf8_lossy(&output.stderr); + let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?; + Some(re.captures(&stderr)?.get(1)?.as_str().to_string()) +} + +#[test] +fn audit_resolves_genesis_commit_to_the_signing_member() { + let cfg = TempDir::new().unwrap(); + let org = TempDir::new().unwrap(); + + let pub_openssh = seed_device(cfg.path(), "test-dev"); + + let init = relicario_with_git_identity( + cfg.path(), + &["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"], + ); + assert!( + init.status.success(), + "org init failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&init.stdout), + String::from_utf8_lossy(&init.stderr) + ); + + // The signing member's pubkey is recorded in members.json. resolve_signer + // builds allowed_signers from exactly that set. + let members_json = + fs::read_to_string(org.path().join("members.json")).expect("read members.json"); + let members: relicario_core::OrgMembers = + serde_json::from_str(&members_json).expect("parse members.json"); + assert_eq!(members.members.len(), 1, "init seeds exactly one owner member"); + let owner = &members.members[0]; + + // The genesis commit must resolve to the owner's fingerprint. + let signing_fp = resolve_signer_fp(org.path(), "HEAD", &[owner.ed25519_pubkey.as_str()]) + .expect("genesis commit signature must verify against the member set"); + let expected = relicario_core::fingerprint(&owner.ed25519_pubkey).expect("fingerprint owner"); + assert_eq!( + signing_fp, expected, + "verified signer fingerprint must equal the owner member's fingerprint" + ); + + // The seeded pubkey and the members.json pubkey are the same key. + assert_eq!(owner.ed25519_pubkey.trim(), pub_openssh.trim()); +} + +#[test] +fn audit_rejects_signature_from_a_non_member_key() { + // A commit signed by the owner must NOT resolve when the allowed_signers set + // contains only some OTHER (non-member) key — this is the TAMPERED path: + // "signer is not a current member". + let cfg = TempDir::new().unwrap(); + let org = TempDir::new().unwrap(); + + let _owner_pub = seed_device(cfg.path(), "test-dev"); + let init = relicario_with_git_identity( + cfg.path(), + &["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"], + ); + assert!(init.status.success(), "org init failed"); + + // A stranger keypair that never signed anything in this repo. + let (_stranger_priv, stranger_pub) = + relicario_core::device::generate_keypair().expect("generate stranger keypair"); + + let resolved = resolve_signer_fp(org.path(), "HEAD", &[stranger_pub.as_str()]); + assert!( + resolved.is_none(), + "a commit signed by the owner must not verify against a stranger-only signer set" + ); +}