Files
relicario/docs/superpowers/salvage/2026-06-20-org-vault-tail/f3e-2-statusaudit.uncommitted.patch
adlee-was-taken 3774047298 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 16:58:28 -04:00

522 lines
21 KiB
Diff

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<MemberId> {
}
}
+// ═══════════ 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<String>,
+ pub actor_id: Option<String>,
+ /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
+ pub trailer_actor_id: Option<String>,
+ pub action: Option<String>,
+ pub collection: Option<String>,
+ pub item_id: Option<String>,
+ pub device_id: Option<String>,
+ /// 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: "<name> <member_id>" (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 <table|json>` (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<String> = 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("<unverified>"),
+ 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<AuditEvent> {
+ let mut events: Vec<AuditEvent> = 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: <name> <member_id>".
+ 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"
+ );
+ }
}