Compare commits

...

10 Commits

Author SHA1 Message Date
adlee-was-taken
d8b23d421e refactor(cli): tidy item_build edit helpers (simplify pass)
- edit_secure_note / edit_key now call the module's resolve_secret_multiline
  instead of open-coding the eprintln-hint + read-to-EOF pattern (the helper
  exists precisely to centralize this; build_secure_note/build_key already use it).
- drop redundant fn-local imports: `use zeroize::Zeroizing;` from the five edit_*
  helpers and the re-imported `TotpAlgorithm` from edit_login/build_login
  (all covered by module-level imports; leftover from the verbatim A2/A3 move).
- build_login passes the password_stdin flag through to resolve_secret_line for
  consistency with build_card/build_totp (behavior identical — that branch is
  only reached when password_stdin is true).
- restore #[allow(clippy::too_many_arguments)] on build_totp (8 args; the old
  build_totp_item carried the same allow — signature is frozen for B/C).
2026-06-20 18:14:10 -04:00
adlee-was-taken
6eb1275710 feat(cli): --*-stdin secret flags for personal add (non-interactive secrets) 2026-06-20 17:56:45 -04:00
adlee-was-taken
751e4e9bb1 chore(cli): remove now-dead prompt/prompt_optional helpers
A3 routed personal `add` through the shared item_build builders, which use
prompt_secret / resolve_secret_*; the generic single-line prompt() and
prompt_optional() lost their last callers. read_required_line /
read_optional_line stay (used by prompt_or_flag*).
2026-06-20 17:40:52 -04:00
adlee-was-taken
65e23cfddc refactor(cli): personal add delegates to shared item_build builders 2026-06-20 17:35:18 -04:00
adlee-was-taken
b83643ee0a refactor(cli): move per-type edit helpers into shared item_build module 2026-06-20 17:27:05 -04:00
adlee-was-taken
154b984725 feat(cli): shared item_build module — secret resolution + type parsers 2026-06-20 17:21:43 -04:00
adlee-was-taken
517d52d517 docs(coordination): v0.8.1 PM + Dev-A/B/C/D kickoff prompts
4-stream manual-pane kickoff (no tmux automation): A foundation, B
Card/Key/Totp, C Document+attachments, D server hook. Each dev prompt
mandates a relay polling cadence (read inbox between every subagent;
HOLD/RESCOPE = interrupt) so PM directives are never missed. Gitea/git
merge mechanism; C<->D attachment-path coordination baked in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 17:10:26 -04:00
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
adlee-was-taken
f27dc72e96 docs(plan): v0.8.1 org item-type parity — 4-stream multi-agent plan
Dev-A shared item_build foundation + personal --*-stdin; Dev-B org
Card/Key/Totp; Dev-C org Document + attachment storage; Dev-D server
hook grant-scoping. TDD tasks with full code; A gates B/C, D independent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 16:48:46 -04:00
adlee-was-taken
b2f3739673 docs(spec): v0.8.1 org item-type parity (Card/Key/Document/Totp) design
Card/Key/Totp = CLI-only parity via shared item-build module; Document
adds org attachment storage + a relicario-server hook change that
grant-scopes attachment paths (closing the Unrestricted gap). Secrets
via interactive prompts + --*-stdin escape hatches. Four suggested dev
streams (A foundation, B Card/Key/Totp, C Document+attachments, D hook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 16:38:05 -04:00
19 changed files with 4071 additions and 455 deletions

View File

@@ -37,15 +37,28 @@ under `src/commands/`. Each source file has one job.
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
`backup` (export / restore), `import` (lastpass), `attach` (attach /
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
builder/editor reads top-to-bottom and can be tested through the same
integration paths.
`rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret
fields then delegate to the shared `item_build` module's per-`ItemCore`
`build_*` / `edit_*` helpers (see the next bullet), so each builder/editor
reads top-to-bottom and can be tested through the same integration paths.
- **`src/commands/item_build.rs`** — shared per-type item construction and
interactive editing used by BOTH personal (`add.rs`, `edit.rs`) and org
(`org.rs`) handlers, so the two surfaces cannot drift. Contains: secret
resolution (`resolve_secret_line` — reads one line from stdin or falls back
to an interactive masked prompt; `resolve_secret_multiline` — reads stdin to
EOF, printing an optional hint in the interactive case); type parsers
(`parse_card_kind`, `parse_totp_algorithm`); the seven `build_*` builders
(`build_login`, `build_secure_note`, `build_identity`, `build_card`,
`build_key`, `build_document`, `build_totp`); per-type `edit_*` helpers
(`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`,
`edit_identity`, `edit_document_message`); and `push_history`.
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
`prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the
flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`.
`prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to
`rpassword`.
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
@@ -167,7 +180,7 @@ in code; cite the line if you change it.
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
directly; `Item::new` (called inside every `item_build::build_*`) 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:
@@ -237,15 +250,23 @@ in code; cite the line if you change it.
### 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,
2. Match on the `AddKind` variant: resolve `title` and non-secret fields
(username, URL, holder, expiry, etc.) via `prompt_or_flag` /
`prompt_or_flag_optional`, then delegate to the matching `build_*` builder
in `commands/item_build.rs`. Seven variants → seven builders; only
`build_document` takes `&UnlockedVault` because it needs `attachment_caps`
and writes the encrypted blob alongside the item.
3. Single-line secrets (Login password, Card number/CVV/PIN, TOTP secret)
accept a `--*-stdin` flag that reads one line from stdin instead of
prompting; multiline secrets (SecureNote body, Key material) always read
stdin to EOF — `--body-stdin` / `--material-stdin` suppress the interactive
Ctrl-D hint. Secret-resolution rule: `commands/item_build.rs`
`resolve_secret_line` / `resolve_secret_multiline`.
4. 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)`,
5. 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
6. 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`).
@@ -578,11 +599,12 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
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
- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the
`3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the
personal and org surfaces share one set). Before the extraction, `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

View File

@@ -1,37 +1,76 @@
//! `relicario add <kind>` — create a new item of the given type.
//!
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
//! `Document` builder is the only one that needs the unlocked vault (for the
//! attachment-cap settings + writing the encrypted blob alongside the item).
//! `cmd_add` resolves `title` / non-secret prompts, then delegates to the
//! shared builders in `commands/item_build.rs`. Group / tags / favorite are
//! set AFTER the build so the builders stay portable to the org vault.
use std::path::PathBuf;
use anyhow::{Context, Result};
use anyhow::Result;
use crate::AddKind;
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret};
use crate::commands::item_build as ib;
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};
pub fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
AddKind::SecureNote { title, body_prompt, group, tags } =>
build_secure_note_item(title, body_prompt, group, tags)?,
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
AddKind::Card { title, holder, expiry, kind, group, tags } =>
build_card_item(title, holder, expiry, kind, group, tags)?,
AddKind::Key { title, label, algorithm, group, tags } =>
build_key_item(title, label, algorithm, group, tags)?,
AddKind::Document { title, file, group, tags } =>
build_document_item(&vault, title, file, group, tags)?,
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
AddKind::Login { title, username, url, password_prompt, password, password_stdin, group, tags, favorite, totp_qr } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
let mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?;
item.group = group; item.tags = tags; item.favorite = favorite;
item
}
AddKind::SecureNote { title, body_stdin, group, tags } => {
// Per the v0.8.1 spec's unified secret model, a note body is a
// multiline secret that always reads stdin to EOF. `body_stdin=false`
// means "print the Ctrl-D hint" (interactive default); `true` suppresses
// the hint for non-interactive use.
// Secret-resolution rule: `commands/item_build.rs` `resolve_secret_multiline`.
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_secure_note(title, None, body_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_identity(title, full_name, email, phone, date_of_birth)?;
item.group = group; item.tags = tags;
item
}
AddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Key { title, label, algorithm, material_stdin, group, tags } => {
// public_key is None for the personal vault: the legacy `prompt_optional`
// for it was unreachable (stdin already at EOF after the key-material read).
// Org `add key` (Dev-B) supplies it via --public-key.
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_key(title, label, algorithm, None, material_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Document { title, file, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let caps = vault.load_settings()?.attachment_caps;
let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?;
item.group = group; item.tags = tags;
let att_dir = vault.root().join("attachments").join(item.id.as_str());
std::fs::create_dir_all(&att_dir)?;
std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
item
}
AddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)?;
item.group = group; item.tags = tags;
item
}
};
vault.save_item(&item)?;
@@ -51,263 +90,3 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn build_login_item(
title: Option<String>,
username: Option<String>,
url: Option<String>,
password_prompt: bool,
password: Option<String>,
group: Option<String>,
tags: Vec<String>,
favorite: bool,
totp_qr: Option<PathBuf>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_prompt {
Some(Zeroizing::new(prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
})
} else {
None
};
let mut item = Item::new(title, ItemCore::Login(LoginCore {
username, password, url: parsed_url, totp,
}));
item.group = group;
item.tags = tags;
item.favorite = favorite;
Ok(item)
}
fn build_secure_note_item(
title: Option<String>,
body_prompt: bool,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::SecureNoteCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let body = if body_prompt {
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
prompt("Body")?
};
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_identity_item(
title: Option<String>,
full_name: Option<String>,
email: Option<String>,
phone: Option<String>,
date_of_birth: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::IdentityCore;
use relicario_core::{Item, ItemCore};
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_card_item(
title: Option<String>,
holder: Option<String>,
expiry: Option<String>,
kind: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{CardCore, CardKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let number = Zeroizing::new(prompt_secret("Card number: ")?);
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
let pin = if pin.is_empty() { None } else { Some(pin) };
let parsed_expiry = match expiry {
Some(s) => Some(parse_month_year(&s)?),
None => None,
};
let parsed_kind = match kind.as_str() {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
};
let mut item = Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_key_item(
title: Option<String>,
label: Option<String>,
algorithm: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::KeyCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
let mut key_material = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
let public_key = prompt_optional("Public key (blank to skip)")?;
let mut item = Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material),
label, public_key, algorithm,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_document_item(
vault: &crate::session::UnlockedVault,
title: Option<String>,
file: PathBuf,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
use std::fs;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
let caps = vault.load_settings()?.attachment_caps;
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(),
mime_type: mime_type.clone(),
primary_attachment: primary_attachment.clone(),
}));
item.group = group;
item.tags = tags;
item.attachments.push(AttachmentRef {
id: primary_attachment.clone(),
filename, mime_type,
size: bytes.len() as u64,
created: item.created,
});
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
Ok(item)
}
#[allow(clippy::too_many_arguments)]
fn build_totp_item(
title: Option<String>,
issuer: Option<String>,
label: Option<String>,
secret: Option<String>,
period: u32,
digits: u8,
algorithm: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let secret_b32 = match secret {
Some(s) => s,
None => prompt_secret("TOTP secret (base32): ")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
let algo = match algorithm.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
};
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: algo,
digits,
period_seconds: period,
kind: TotpKind::Totp,
},
issuer, label,
}));
item.group = group;
item.tags = tags;
Ok(item)
}

View File

@@ -2,10 +2,9 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use anyhow::Result;
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
use crate::prompt::{prompt_keep, prompt_keep_opt};
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
use relicario_core::time::now_unix;
@@ -29,13 +28,13 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
let history = &mut item.field_history;
match &mut item.core {
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
ItemCore::Identity(i) => edit_identity(i)?,
ItemCore::Card(c) => edit_card(c, history)?,
ItemCore::Key(k) => edit_key(k, history)?,
ItemCore::Document(_) => edit_document_message(),
ItemCore::Totp(t) => edit_totp(t, history)?,
ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?,
ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?,
ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?,
ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?,
ItemCore::Document(_) => crate::commands::item_build::edit_document_message(),
ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?,
}
item.modified = now_unix();
@@ -47,125 +46,3 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
eprintln!("Updated {}", item.id.as_str());
Ok(())
}
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
// that touch history-tracked fields take the item's field_history map so
// they can record the prior value alongside the change.
type FieldHistory = std::collections::HashMap<
relicario_core::FieldId,
Vec<relicario_core::item::FieldHistoryEntry>,
>;
fn edit_login(
l: &mut relicario_core::item_types::LoginCore,
history: &mut FieldHistory,
totp_qr: Option<PathBuf>,
) -> Result<()> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
}
if prompt_yesno("Change password?")? {
let old = l.password.clone();
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
if let Some(old_pw) = old {
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
}
}
if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
l.totp = Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
});
eprintln!("TOTP secret set from QR image.");
}
Ok(())
}
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if prompt_yesno("Edit body?")? {
let old = n.body.clone();
eprintln!("Enter new body; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
n.body = Zeroizing::new(s);
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
Ok(())
}
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
if prompt_yesno("Change card number?")? {
let old = c.number.clone();
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
if let Some(o) = old {
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
}
}
Ok(())
}
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if prompt_yesno("Replace key material?")? {
eprintln!("Paste new key material; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
let old = k.key_material.clone();
k.key_material = Zeroizing::new(s);
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
fn edit_document_message() {
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
if prompt_yesno("Change TOTP secret?")? {
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
let new_bytes = base32_decode_lenient(&new_b32)?;
t.config.secret = Zeroizing::new(new_bytes);
push_history(history, "totp_secret", Zeroizing::new(old_b32));
}
Ok(())
}
fn push_history(
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
use relicario_core::item::FieldHistoryEntry;
use relicario_core::time::now_unix;
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}

View File

@@ -0,0 +1,318 @@
//! Shared per-type item construction + interactive editing for both the
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use zeroize::Zeroizing;
use relicario_core::item::FieldHistoryEntry;
use relicario_core::item_types::{CardKind, TotpAlgorithm};
use relicario_core::time::now_unix;
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
/// Resolve a single-line secret: from stdin when `from_stdin`, else an
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
if from_stdin {
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
Ok(s.trim_end_matches(['\n', '\r']).to_string())
} else {
crate::prompt::prompt_secret(&format!("{label}: "))
}
}
/// Resolve a multiline secret (key material, note body). Both paths read stdin
/// to EOF; the interactive path first prints `hint` to stderr.
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
if !from_stdin {
eprintln!("{hint}");
}
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
Ok(s)
}
pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
Ok(match s {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
})
}
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
Ok(match s.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
})
}
// --- Per-type interactive edit helpers (moved from commands/edit.rs). Each
// mutates its core slice in place; history-tracked variants take the
// item's field_history map so they can record the prior value.
pub(crate) fn edit_login(
l: &mut relicario_core::item_types::LoginCore,
history: &mut FieldHistory,
totp_qr: Option<PathBuf>,
) -> Result<()> {
use relicario_core::item_types::{TotpConfig, TotpKind};
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
}
if prompt_yesno("Change password?")? {
let old = l.password.clone();
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
if let Some(old_pw) = old {
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
}
}
if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
l.totp = Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
});
eprintln!("TOTP secret set from QR image.");
}
Ok(())
}
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
if prompt_yesno("Edit body?")? {
let old = n.body.clone();
let s = resolve_secret_multiline(false, "Enter new body; end with Ctrl-D:")?;
n.body = Zeroizing::new(s);
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
Ok(())
}
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
if prompt_yesno("Change card number?")? {
let old = c.number.clone();
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
if let Some(o) = old {
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
}
}
Ok(())
}
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
if prompt_yesno("Replace key material?")? {
let s = resolve_secret_multiline(false, "Paste new key material; end with Ctrl-D:")?;
let old = k.key_material.clone();
k.key_material = Zeroizing::new(s);
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
pub(crate) fn edit_document_message() {
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
if prompt_yesno("Change TOTP secret?")? {
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
let new_bytes = base32_decode_lenient(&new_b32)?;
t.config.secret = Zeroizing::new(new_bytes);
push_history(history, "totp_secret", Zeroizing::new(old_b32));
}
Ok(())
}
pub(crate) fn build_login(
title: String, username: Option<String>, url: Option<String>,
password: Option<String>, password_stdin: bool, password_prompt: bool,
totp_qr: Option<PathBuf>,
) -> Result<Item> {
use relicario_core::item_types::{LoginCore, TotpConfig, TotpKind};
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_stdin {
Some(Zeroizing::new(resolve_secret_line(password_stdin, "Password")?))
} else if password_prompt {
Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes), algorithm: TotpAlgorithm::Sha1,
digits: 6, period_seconds: 30, kind: TotpKind::Totp,
})
} else { None };
Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
}
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
use relicario_core::item_types::SecureNoteCore;
let body = match body {
Some(b) => b,
None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
};
Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
}
pub(crate) fn build_identity(
title: String, full_name: Option<String>, email: Option<String>,
phone: Option<String>, date_of_birth: Option<String>,
) -> Result<Item> {
use relicario_core::item_types::IdentityCore;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
Ok(Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
})))
}
pub(crate) fn build_card(
title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
) -> Result<Item> {
use relicario_core::item_types::CardCore;
let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
Ok(Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
})))
}
pub(crate) fn build_key(
title: String, label: Option<String>, algorithm: Option<String>,
public_key: Option<String>, material_stdin: bool,
) -> Result<Item> {
use relicario_core::item_types::KeyCore;
let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
Ok(Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material), label, public_key, algorithm,
})))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_totp(
title: String, issuer: Option<String>, label: Option<String>,
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
) -> Result<Item> {
use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
let secret_b32 = match secret {
Some(s) => s,
None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Ok(Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
digits, period_seconds: period, kind: TotpKind::Totp,
},
issuer, label,
})))
}
pub(crate) fn build_document(
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
) -> Result<(Item, EncryptedAttachment)> {
use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef};
let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy().into_owned();
let mime_type = crate::parse::guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(),
}));
item.attachments.push(AttachmentRef {
id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
});
Ok((item, enc))
}
pub(crate) fn push_history(
history: &mut FieldHistory,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}
#[cfg(test)]
mod tests {
use super::*;
use relicario_core::item_types::{CardKind, TotpAlgorithm};
#[test]
fn card_kind_parses_known_values() {
assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
}
#[test]
fn card_kind_rejects_unknown() {
assert!(parse_card_kind("platinum").is_err());
}
#[test]
fn totp_algorithm_is_case_insensitive() {
assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
}
#[test]
fn totp_algorithm_rejects_unknown() {
assert!(parse_totp_algorithm("md5").is_err());
}
}

View File

@@ -14,6 +14,7 @@ pub mod edit;
pub mod generate;
pub mod get;
pub mod import;
pub mod item_build;
pub mod org;
pub mod init;
pub mod list;

View File

@@ -227,6 +227,8 @@ pub(crate) enum AddKind {
/// Prompt for password (vs reading from stdin or --password).
#[arg(long)] password_prompt: bool,
#[arg(long)] password: Option<String>,
/// Read the password from stdin (one line) instead of prompting.
#[arg(long)] password_stdin: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] favorite: bool,
@@ -235,7 +237,8 @@ pub(crate) enum AddKind {
},
SecureNote {
#[arg(long)] title: Option<String>,
#[arg(long)] body_prompt: bool,
/// Read the note body from stdin (to EOF) instead of printing the Ctrl-D hint.
#[arg(long)] body_stdin: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
@@ -253,6 +256,12 @@ pub(crate) enum AddKind {
#[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>, // MM/YYYY
#[arg(long, default_value = "credit")] kind: String,
/// Read the card number from stdin (one line) instead of prompting.
#[arg(long)] number_stdin: bool,
/// Read the CVV from stdin (one line) instead of prompting.
#[arg(long)] cvv_stdin: bool,
/// Read the PIN from stdin (one line) instead of prompting.
#[arg(long)] pin_stdin: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
@@ -260,6 +269,8 @@ pub(crate) enum AddKind {
#[arg(long)] title: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] algorithm: Option<String>,
/// Read the key material from stdin (to EOF) instead of printing the Ctrl-D hint.
#[arg(long)] material_stdin: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
@@ -274,6 +285,8 @@ pub(crate) enum AddKind {
#[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>, // base32
/// Read the TOTP secret from stdin (one line) instead of prompting.
#[arg(long)] secret_stdin: bool,
#[arg(long, default_value = "30")] period: u32,
#[arg(long, default_value = "6")] digits: u8,
#[arg(long, default_value = "sha1")] algorithm: String,

View File

@@ -1,13 +1,12 @@
//! Interactive prompt helpers for the CLI.
//!
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! `prompt_secret` reads a masked secret from the TTY (honouring
//! `RELICARIO_TEST_ITEM_SECRET` so integration tests without a TTY can inject
//! secrets); the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! used by the edit handlers to keep current values when the user hits enter
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
//! so integration tests (which don't have a TTY) can inject secrets.
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
//! through the same path so command handlers can use one call site whether
//! the value came from the command line or from an interactive prompt.
//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a
//! CLI-flag value through the same path so command handlers can use one call
//! site whether the value came from the command line or an interactive prompt.
use anyhow::Result;
use std::io::BufRead;
@@ -41,18 +40,6 @@ fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt(label: &str) -> Result<String> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
read_required_line(&mut reader, label)
}
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
read_optional_line(&mut reader, label)
}
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
eprint!("{label} [{current}]: ");
std::io::Write::flush(&mut std::io::stderr())?;

View File

@@ -201,3 +201,20 @@ fn generate_random_and_bip39() {
let phrase = String::from_utf8(out.stdout).unwrap();
assert_eq!(phrase.trim().split(' ').count(), 5);
}
#[test]
fn add_card_via_stdin_flags_is_non_interactive() {
let v = TestVault::init();
let out = v.run_with_input(
&["add", "card", "--title", "Visa", "--kind", "credit",
"--number-stdin", "--cvv-stdin", "--pin-stdin"],
&["4111111111111111", "123", "4321"],
);
assert!(out.status.success(), "add card via stdin failed: {}", String::from_utf8_lossy(&out.stderr));
let got = v.run(&["get", "Visa"]);
assert!(got.status.success(), "get Visa failed: {}", String::from_utf8_lossy(&got.stderr));
let stdout = String::from_utf8_lossy(&got.stdout);
assert!(stdout.contains("********"), "card number should be masked without --show: {stdout}");
assert!(!stdout.contains("4111111111111111"), "card number leaked without --show: {stdout}");
}

View File

@@ -0,0 +1,134 @@
# Dev A Kickoff Prompt — v0.8.1 Stream A (shared item-build foundation)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream A for the v0.8.1 "org item-type parity" release.
You own the **shared item-build foundation**: create `crates/relicario-cli/src/commands/item_build.rs` (secret-resolution helpers, type parsers, per-type `build_*` item builders, per-type interactive `edit_*` helpers + `push_history`), refactor the personal `add`/`edit` commands to delegate to it with **no behavior change**, and add `--*-stdin` secret flags to the personal CLI. **Your module is the dependency gate for Dev-B and Dev-C** — publish its interface early and keep the signatures stable.
A PM in another terminal coordinates you with Dev-B, Dev-C, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git branch --list feature/v0.8.1-dev-a-foundation # ensure no collision; escalate if it exists
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-a -b feature/v0.8.1-dev-a-foundation
cd /home/alee/Sources/relicario.v0.8.1-dev-a
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-a
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-a`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-a` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. This is non-negotiable.
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`:
- `post_message(from, to, kind, body)` — your `from` is always `"dev-a"`
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
- `list_pending(for)` — check inbox count
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-a")`. After any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
**Fallback** (relay tools not registered):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-a"}'
```
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
## Relay polling cadence — MANDATORY (do NOT go head-down)
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task.
**Call `read_messages(for="dev-a")` (run `list_pending(for="dev-a")` first if you want a cheap check) at ALL of these points:**
- Before dispatching EACH subagent — and again the moment it returns.
- Before EACH commit, and at the start + end of every task/step.
- Any time you've been heads-down for more than a few minutes.
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.1 shared module + §Design.2/.3 personal `--*-stdin`**)
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-A** section, Tasks A1A4, task by task
## Execution mode
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.v0.8.1-dev-a
```
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
## Your scope and boundaries
**In scope:** Tasks A1 (shared module scaffold: secret resolution + parsers), A2 (move interactive `edit_*` helpers + `push_history`), A3 (move the seven `build_*` builders; personal `cmd_add` delegates), A4 (personal `--*-stdin` flags + CLI ARCHITECTURE doc).
**Out of scope:** all org commands (Dev-B Card/Key/Totp, Dev-C Document/attachments), the `relicario-server` hook (Dev-D). If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
**Hard rules:**
- **A is behavior-preserving for the personal vault.** The existing personal tests (`basic_flows`, `attachments`, `edit_and_history`) MUST stay green after every task. Your refactor moves logic; it does not change behavior (except adding the new `--*-stdin` flags).
- **Your public interface is a contract.** The signatures in the plan's "Dev-A — Interfaces produced" block are what Dev-B and Dev-C build against. Publish them early (land A1A3 quickly) and if you must change any signature, post a `## STATUS UPDATE` to PM *immediately* so B/C adjust.
- Do not merge your branch — the PM merges (you're first in the merge order).
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
## Coordination protocol
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
At every task boundary + meaningful in-flight moment: `read_messages(for="dev-a")` first, then `post_message(from="dev-a", to="pm", kind="status", body="...")`. Format:
```
## STATUS UPDATE — DEV-A
Time: <iso8601>
Branch: feature/v0.8.1-dev-a-foundation
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which) | N/A>
Notes: <≤3 sentences>
```
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-A` (Context / Options / Recommended / Blocker: yes|no).
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed, no per-edit confirmations. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (catch duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. No parallel implementations of an existing helper. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
## Escalate to PM when
A scope question outside the plan; a test you can't green after honest debugging; a discovered bug not in your plan; anything destructive; before REVIEW-READY.
## Final steps before REVIEW-READY
Run full validation from the worktree:
```bash
cargo test -p relicario-cli
cargo build -p relicario-cli
cargo clippy -p relicario-cli --all-targets
```
Then push your branch (this project uses Gitea; the **PM merges via git**, so you do NOT open a GitHub PR):
```bash
git push -u origin feature/v0.8.1-dev-a-foundation
```
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log` (never a guessed SHA).
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-a-foundation`, plan absorbed), acknowledge you are the dependency gate for B/C, then start Task A1.

View File

@@ -0,0 +1,134 @@
# Dev B Kickoff Prompt — v0.8.1 Stream B (org Card/Key/Totp parity)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream B for the v0.8.1 "org item-type parity" release.
You own **org `add`/`edit` parity for Card, Key, and Totp**: extend `commands::org::OrgAddKind` + the `main.rs` clap surface with those three types, wire them to Dev-A's shared builders, convert org `edit` to per-type interactive dispatch (reusing Dev-A's `edit_*` helpers), and add the `org_items` integration tests. You establish the **org per-type dispatch skeleton** in `commands/org.rs` that Dev-C later extends with Document.
A PM in another terminal coordinates you with Dev-A, Dev-C, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git branch --list feature/v0.8.1-dev-b-card-key-totp # ensure no collision; escalate if it exists
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-b -b feature/v0.8.1-dev-b-card-key-totp
cd /home/alee/Sources/relicario.v0.8.1-dev-b
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-b
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-b`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-b` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`:
- `post_message(from, to, kind, body)` — your `from` is always `"dev-b"`
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
- `list_pending(for)` — check inbox count
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-b")`. After any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
**Fallback** (relay tools not registered):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-b"}'
```
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
## Relay polling cadence — MANDATORY (do NOT go head-down)
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task.
**Call `read_messages(for="dev-b")` (run `list_pending(for="dev-b")` first if you want a cheap check) at ALL of these points:**
- Before dispatching EACH subagent — and again the moment it returns.
- Before EACH commit, and at the start + end of every task/step.
- Any time you've been heads-down for more than a few minutes.
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.2/.3, the Card/Key/Totp slice of org add/edit**)
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-B** section, Tasks B1B4, task by task. Also read the **Dev-A — Interfaces produced** block: that is the contract you build against.
## Execution mode
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.v0.8.1-dev-b
```
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
## Your scope and boundaries
**In scope:** Tasks B1 (extend `commands::org::OrgAddKind` + `build_org_item` to delegate to Dev-A's builders for Card/Key/Totp), B2 (`main.rs` clap `OrgAddKind` Card/Key/Totp variants + `--*-stdin` flags + dispatch), B3 (convert `run_edit` to per-type interactive dispatch via shared `edit_*` helpers), B4 (`org_items` round-trip tests for Card/Key/Totp).
**Out of scope:** Dev-A's shared module itself, Dev-C's Document/attachment work, Dev-D's `relicario-server` hook. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
**Hard rules:**
- **You consume Dev-A's `crate::commands::item_build`.** Do NOT duplicate builder/edit logic — call Dev-A's published functions. Dev-A merges before you integrate; the PM coordinates this. You may scaffold + write your failing tests against A's documented interface while you wait, but don't reimplement A.
- **Keep the org dispatch skeleton clean and additive.** Dev-C extends your `OrgAddKind` / `run_add` / `run_edit` with a Document arm and adds a `file` param to `run_edit`. Structure your dispatch so a fourth type slots in without a rewrite.
- Secrets via interactive prompts by default + `--*-stdin`. **`org get` must mask secrets without `--show`** — assert this in B4.
- Do not merge your branch — the PM merges (you merge after Dev-A, before Dev-C).
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
## Coordination protocol
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
```
## STATUS UPDATE — DEV-B
Time: <iso8601>
Branch: feature/v0.8.1-dev-b-card-key-totp
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which) | N/A>
Notes: <≤3 sentences>
```
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-B` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-B` blocks — acknowledge and act.
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. Do not reimplement a Dev-A helper. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
## Escalate to PM when
A scope question outside the plan; a test you can't green after honest debugging; a discovered bug not in your plan; a needed change to Dev-A's interface; anything destructive; before REVIEW-READY.
## Final steps before REVIEW-READY
Run full validation from the worktree:
```bash
cargo test -p relicario-cli --test org_items
cargo test -p relicario-cli
cargo build -p relicario-cli
cargo clippy -p relicario-cli --all-targets
```
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
```bash
git push -u origin feature/v0.8.1-dev-b-card-key-totp
```
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-b-card-key-totp`, plan + Dev-A interface absorbed). Note that you depend on Dev-A and ask the PM to confirm Dev-A's interface is stable before you integrate. Start Task B1 (you can write failing tests against A's documented signatures immediately).

View File

@@ -0,0 +1,135 @@
# Dev C Kickoff Prompt — v0.8.1 Stream C (org Document + attachment storage)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream C for the v0.8.1 "org item-type parity" release.
You own **org Document support + collection-scoped attachment storage**: add `org_session` attachment methods (`attachment_path` / `save_attachment` / `load_attachment` / `remove_item_attachments`) + a default cap constant, add the Document arm to org `add`/`edit` (via `--file`, using Dev-A's `build_document`), make `purge` remove attachments, and update `docs/FORMATS.md`. You depend on **Dev-A** (`build_document`) and **Dev-B** (you extend B's org dispatch skeleton — B merges before you).
A PM in another terminal coordinates you with Dev-A, Dev-B, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git branch --list feature/v0.8.1-dev-c-document-attachments # ensure no collision; escalate if it exists
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-c -b feature/v0.8.1-dev-c-document-attachments
cd /home/alee/Sources/relicario.v0.8.1-dev-c
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-c
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-c`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-c` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`:
- `post_message(from, to, kind, body)` — your `from` is always `"dev-c"`
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
- `list_pending(for)` — check inbox count
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-c")`. After any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
**Fallback** (relay tools not registered):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-c"}'
```
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
## Relay polling cadence — MANDATORY (do NOT go head-down)
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task — and you have a live coordination dependency with Dev-D (see below), so an unread message is especially costly here.
**Call `read_messages(for="dev-c")` (run `list_pending(for="dev-c")` first if you want a cheap check) at ALL of these points:**
- Before dispatching EACH subagent — and again the moment it returns.
- Before EACH commit, and at the start + end of every task/step.
- Any time you've been heads-down for more than a few minutes.
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.4, org Document + attachment storage**)
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-C** section, Tasks C1C4, task by task. Also read **Dev-A — Interfaces produced** (`build_document`) and the **Dev-B** section (the dispatch skeleton you extend).
## Execution mode
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.v0.8.1-dev-c
```
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
## Your scope and boundaries
**In scope:** Tasks C1 (`org_session` attachment methods + `DEFAULT_ORG_ATTACHMENT_MAX_BYTES`), C2 (org `add document` + commit the attachment path), C3 (`purge` removes attachments + Document edit via `--file`), C4 (org Document integration tests + `docs/FORMATS.md`).
**Out of scope:** Dev-A's shared module, Dev-B's Card/Key/Totp, Dev-D's `relicario-server` hook. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
**Hard rules:**
- **You depend on Dev-A (`build_document`) and Dev-B (org dispatch skeleton).** B merges before you — rebase on B's `run_add`/`run_edit`. Don't reimplement A's builder or B's dispatch; extend them. You may scaffold + write failing tests against the documented interfaces while you wait.
- **C↔D attachment-path agreement (CRITICAL):** your storage layout is `attachments/<slug>/<item-id>/<att-id>.enc` — exactly **3 path segments** after `attachments/`. Dev-D's `classify_path` must authorize precisely this shape. **Confirm the exact path shape with Dev-D (via the PM) before you finalize C1**, and re-confirm if either side changes it. A mismatch means the hook rejects legitimate writes or leaves the authz gap open.
- **Cap = a default constant**, value taken from the personal-vault default in `crates/relicario-core/src/settings.rs` (`attachment_caps.per_attachment_max_bytes`). Verify the real value; cite the source line in a doc comment. Do not guess.
- When `run_edit` gains the `file` param (C3), update Dev-B's `run_edit` signature AND its `main.rs` dispatch together.
- Do not merge your branch — the PM merges (you merge last among the CLI streams, after Dev-B).
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
## Coordination protocol
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
```
## STATUS UPDATE — DEV-C
Time: <iso8601>
Branch: feature/v0.8.1-dev-c-document-attachments
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which) | N/A>
Notes: <≤3 sentences>
```
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-C` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-C` blocks — acknowledge and act. **Proactively coordinate the attachment path shape with Dev-D through the PM early.**
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. Reuse Dev-A's `build_document` + the existing `encrypt_attachment`/`decrypt_attachment` — don't reimplement. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
## Escalate to PM when
A scope question outside the plan; a test you can't green after honest debugging; any attachment-path-shape disagreement with Dev-D; a needed change to Dev-A's or Dev-B's interface; anything destructive; before REVIEW-READY.
## Final steps before REVIEW-READY
Run full validation from the worktree:
```bash
cargo test -p relicario-cli --test org_items
cargo test -p relicario-cli
cargo build -p relicario-cli
cargo clippy -p relicario-cli --all-targets
```
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
```bash
git push -u origin feature/v0.8.1-dev-c-document-attachments
```
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-c-document-attachments`, plan + Dev-A/Dev-B interfaces absorbed). **Immediately post a `## QUESTION TO PM` proposing the attachment path shape `attachments/<slug>/<item-id>/<att-id>.enc` and asking the PM to confirm it with Dev-D.** Then start Task C1 (you can build `org_session` attachment storage + its unit test immediately — it depends only on core, not on B).

View File

@@ -0,0 +1,133 @@
# Dev D Kickoff Prompt — v0.8.1 Stream D (server hook: grant-scope attachment paths)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream D for the v0.8.1 "org item-type parity" release.
You own the **`relicario-server` pre-receive hook change**: extend `classify_path` (`crates/relicario-server/src/lib.rs`) to recognize `attachments/<slug>/<item-id>/<att-id>.enc` and classify it as `PathClass::Item { collection: slug }` — converting attachment writes from `Unrestricted` to grant-scoped (closing a latent authz gap). Add server tests, bump the `relicario-server` version, and note the required server redeploy in `docs/SECURITY.md`. **You are fully independent of the CLI streams — start immediately.**
A PM in another terminal coordinates you with Dev-A, Dev-B, Dev-C. With the relay running you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git branch --list feature/v0.8.1-dev-d-server-hook # ensure no collision; escalate if it exists
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-d -b feature/v0.8.1-dev-d-server-hook
cd /home/alee/Sources/relicario.v0.8.1-dev-d
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-d
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-d`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-d` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`:
- `post_message(from, to, kind, body)` — your `from` is always `"dev-d"`
- `read_messages(for)` — drain your inbox; call with `for="dev-d"` before each task
- `list_pending(for)` — check inbox count
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-d")`. After any status/question block: `post_message(from="dev-d", to="pm", kind="status"|"question", body="...")`.
**Fallback** (relay tools not registered):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-d","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-d"}'
```
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
## Relay polling cadence — MANDATORY (do NOT go head-down)
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. You also have a live coordination dependency with Dev-C (the attachment path shape — see below), so an unread message can mean your hook and their storage disagree.
**Call `read_messages(for="dev-d")` (run `list_pending(for="dev-d")` first if you want a cheap check) at ALL of these points:**
- Before dispatching EACH subagent — and again the moment it returns.
- Before EACH commit, and at the start + end of every task/step.
- Any time you've been heads-down for more than a few minutes.
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered late costs rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.5, the hook change**)
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-D** section, Task D1, task by task
## Execution mode
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.v0.8.1-dev-d
```
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
## Your scope and boundaries
**In scope:** Task D1 — extend `classify_path` in `crates/relicario-server/src/lib.rs` for the `attachments/` branch; add classification tests to `crates/relicario-server/tests/org_hook.rs`; bump `relicario-server` version in `Cargo.toml`; note the grant-scoping change + required hook redeploy in `docs/SECURITY.md`.
**Out of scope:** all CLI work (Dev-A/B/C). The hook's `main.rs` authorization loop already handles `PathClass::Item { collection }` — you should NOT need to touch `main.rs`; if you think you do, escalate to PM first. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
**Hard rules:**
- **C↔D attachment-path agreement (CRITICAL):** you authorize the path shape `attachments/<slug>/<item-id>/<att-id>.enc` — exactly **3 path segments** after `attachments/`. This MUST match Dev-C's storage layout exactly. **Confirm the path shape with Dev-C (via the PM) before you finalize** the `classify_path` branch. A mismatch rejects legitimate writes or leaves the gap open.
- **Security-critical, do not relax the guards.** Mirror the existing `items/` branch defenses: exact segment count and a `.`-free slug guard (path-traversal defense). The `slug` you return as `collection` is what the existing grant + slug-existence check authorizes against.
- The existing `org_hook.rs` tests MUST stay green; add new ones, don't weaken old ones.
- Do not merge your branch — the PM merges (any order; you're independent).
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
## Coordination protocol
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
```
## STATUS UPDATE — DEV-D
Time: <iso8601>
Branch: feature/v0.8.1-dev-d-server-hook
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which) | N/A>
Notes: <≤3 sentences>
```
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-D` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-D` blocks — acknowledge and act. **Proactively confirm the attachment path shape with Dev-C through the PM early** — you'll likely finish before the CLI streams, so lock the contract before you go REVIEW-READY.
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Mirror the existing `items/` branch structure — don't invent a divergent pattern. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
## Escalate to PM when
A scope question outside the plan; a test you can't green after honest debugging; any attachment-path-shape disagreement with Dev-C; if you think you need to touch `main.rs`; anything destructive; before REVIEW-READY.
## Final steps before REVIEW-READY
Run full validation from the worktree:
```bash
cargo test -p relicario-server
cargo build -p relicario-server
cargo clippy -p relicario-server --all-targets
```
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
```bash
git push -u origin feature/v0.8.1-dev-d-server-hook
```
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-d-server-hook`, plan absorbed). **Immediately post a `## QUESTION TO PM` to confirm the attachment path shape `attachments/<slug>/<item-id>/<att-id>.enc` with Dev-C.** Then start Task D1 — you're independent, so go.

View File

@@ -0,0 +1,138 @@
# PM Kickoff Prompt — v0.8.1 org item-type parity
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for the v0.8.1 "org item-type parity" release. 4 senior developers report to you, each working in their own terminal on a parallel feature branch + git worktree. The user runs all 5 terminals (manual kitty panes) and the relay routes messages between them.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Branch: stay on `main`. Do not check out feature branches.
- Today: 2026-06-20. Project rules in `CLAUDE.md` apply (note: Mexican-Spanish flourish in replies, Relicario capitalization, ask before destructive git ops).
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
**Fallback:** If the relay MCP tools are not registered in your session (the relay server was not running when your session opened), use the Python shim:
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
python3 call.py read_messages '{"for":"pm"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — the spec
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — the single plan; all four streams (Dev-A/B/C/D) live in this one file. Read the whole plan, especially the **Stream dependency graph** and the per-stream Interfaces blocks.
## The four streams + their dependency graph
- **Dev-A** — shared `commands/item_build.rs` foundation (secret resolution, builders, edit helpers) + personal `add`/`edit` refactor + personal `--*-stdin`. **Gates B and C.**
- **Dev-B** — org `add`/`edit` parity for Card/Key/Totp. Depends on A; establishes the org per-type dispatch skeleton in `commands/org.rs`.
- **Dev-C** — org Document + collection-scoped attachment storage. Depends on A (`build_document`) **and B** (extends B's org dispatch skeleton — **B merges before C**).
- **Dev-D** — `relicario-server` hook: grant-scope `attachments/<slug>/…` paths. **Fully independent — clear it to start immediately.**
**Merge order you must enforce:** D may merge anytime. **A merges first**, then **B**, then **C** (C rebases on B). Never let B or C merge before A.
## Your authority
- Approve or deny scope changes from devs
- Review each dev's branch and merge it to `main` (**you merge via git — see below**)
- Drive release-prep work that isn't a feature stream (CHANGELOG, version bumps to v0.8.1, STATUS/ROADMAP, the final integration sweep)
- Tag `v0.8.1` once everything is integrated **— only after explicit user approval**
## Your boundaries
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` are fine.
- Don't deviate from the spec without user approval.
- Don't merge a branch until the dev says `REVIEW-READY` and you've reviewed the diff.
- Don't tag without user approval.
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`).
## Judgment calls / coordination points worth flagging
The plan flagged these for your awareness:
- **Dev-A's `item_build` public interface is a CONTRACT.** Dev-B and Dev-C build against the signatures in the plan's "Dev-A — Interfaces produced" block. If Dev-A must change a signature, it must be announced on the relay *immediately* so B/C adjust.
- **C↔D attachment-path agreement.** Dev-C's storage layout (`attachments/<slug>/<item-id>/<att-id>.enc`, 3 path segments) MUST exactly match the shape Dev-D authorizes in `classify_path`. Get both to confirm the path shape with each other (via you) before either finalizes.
- **`run_edit` signature seam (B→C).** Dev-B writes `run_edit(dir, query, totp_qr)`; Dev-C's C3 adds a `file` param to that same function. Make sure C updates B's signature + the `main.rs` dispatch together when rebasing.
- **Cap constant.** Dev-C uses a default attachment cap constant that must match the personal-vault default in `crates/relicario-core/src/settings.rs` (cite the source line). Confirm the value is verified, not guessed.
- **Server redeploy.** Dev-D's hook change requires rebuilding the deployed pre-receive hook. The release notes/CHANGELOG must call this out.
## Coordination protocol
With the relay running, use `post_message` / `read_messages` directly — call `read_messages(for="pm")` before every action. If the relay tools aren't registered, fall back to the Python shim or ask the user to relay.
**Narrate to the user in plain prose between tool calls.** The PM terminal is the user's main window into the release. When a STATUS UPDATE lands, summarize it in a sentence or two before deciding. When you send a directive, state the rationale. When you dispatch a review subagent, say so. One or two sentences per beat — the user should read this terminal top-to-bottom and follow the release as a story.
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks.
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post via `post_message` and print it here. Format:
```
## DIRECTIVE TO DEV-<letter>
Time: <iso8601>
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
Notes: <one paragraph max>
Next: <one concrete instruction or "continue plan">
```
**Confirm your directives are actually seen.** Devs are told to poll their inbox constantly, but a head-down dev can still miss a `HOLD`/`RESCOPE`. After you post a `HOLD` or `RESCOPE`, watch that dev's next STATUS UPDATE for an explicit acknowledgement. If the dev keeps posting forward progress as if nothing changed (no ack, still dispatching subagents on the old premise), do NOT assume it landed — tell the user in plain prose to nudge that terminal directly ("Dev-C hasn't acked the HOLD — can you poke that pane?"). An unacknowledged HOLD is a blocker, not a sent-and-forget.
When the user asks "status?", give a rollup:
```
## RELEASE STATUS — v0.8.1
Devs: <per-dev one-line state>
PM: <what you're working on>
Blockers: <list, or "none">
Next milestone: <e.g., "Dev-A REVIEW-READY → unblocks B/C">
```
## Reviewing + merging branches (Gitea, not GitHub — `gh` is unusable here)
When a dev posts `Action: REVIEW-READY` with a branch name:
1. `git fetch origin`
2. `git log --oneline main..origin/<branch>` and `git diff main...origin/<branch>` — read the changes
3. Check the diff against the spec + that stream's plan tasks. Optionally dispatch a fresh subagent with `superpowers:requesting-code-review` for a deeper independent pass.
4. If green, **merge via git** (preserve history — no squash) and verify origin twice before pushing:
```bash
git checkout main && git pull --ff-only
git merge --no-ff origin/<branch> -m "merge: <branch> (v0.8.1 Dev-<letter>)"
git remote -v # verify origin is the Relicario remote, twice, before pushing
git push origin main
```
Then post `Action: MERGE-APPROVED` to that dev.
5. If red, post `Action: HOLD` with specific concerns.
Do not put unread/guessed SHAs in relay messages — only SHAs you've actually read from `git log`.
## Pre-tag checklist
Before tagging `v0.8.1`:
- [ ] Dev-A merged first; then Dev-B; then Dev-C; Dev-D merged (any order)
- [ ] Version bumped to 0.8.1 (relicario-core/cli/wasm) + relicario-server patch bump; CHANGELOG written; STATUS.md / ROADMAP.md updated
- [ ] `cargo test` (all crates) green on main + `cargo build -p relicario-wasm --target wasm32-unknown-unknown`
- [ ] `cd extension && npm run build:all` clean (extension untouched, but verify the workspace)
- [ ] Release notes call out the **coordinated relicario-server redeploy** (rebuild the pre-receive hook)
- [ ] User-driven smoke test of the merged result
- [ ] Explicit user approval to tag
## First action
1. `read_messages(for="pm")` to drain early inbox messages.
2. Emit a `## RELEASE STATUS` block confirming you've absorbed the spec + plan, and list the dependency/merge order + the C↔D coordination point for the user.
3. Send opening directives: clear **Dev-A** and **Dev-D** to start immediately; tell **Dev-B** and **Dev-C** to create their worktrees + read + write failing tests against Dev-A's published interface, but hold integration until A merges (B before C).
4. Wait for acknowledgement STATUS UPDATEs from all four devs before clearing them to proceed.

File diff suppressed because it is too large Load Diff

View File

@@ -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 (B9B13) | **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/`.

View File

@@ -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<MemberId> {
}
}
+// ═══════════ Item CRUD (B9-B13) ═══════════
+//
+// `org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge` for items
+// stored under `items/<collection-slug>/<id>.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<String>,
+ url: Option<String>,
+ password: Option<String>,
+ },
+ SecureNote {
+ title: String,
+ body: String,
+ },
+ Identity {
+ title: String,
+ full_name: Option<String>,
+ email: Option<String>,
+ phone: Option<String>,
+ },
+}
+
+/// Build a typed `Item` from a non-interactive `OrgAddKind` plus tags.
+fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
+ 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<String>) -> 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<String>,
+) -> 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<Zeroizing<String>> = 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<String>,
+ username: Option<String>,
+ url: Option<String>,
+ password: Option<String>,
+ body: Option<String>,
+ email: Option<String>,
+ phone: Option<String>,
+ full_name: Option<String>,
+) -> 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<String>,
+ username: Option<String>,
+ url: Option<String>,
+ password: Option<String>,
+ body: Option<String>,
+ email: Option<String>,
+ phone: Option<String>,
+ full_name: Option<String>,
+) -> 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<String>) -> 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", &note.id).unwrap();
+ match &note_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:#}");
+ }
+}

View File

@@ -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<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"
+ );
+ }
}

View File

@@ -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 `<config_home>/relicario/devices/<name>/` 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<String> {
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"
);
}

View File

@@ -0,0 +1,107 @@
# Relicario v0.8.1 — Org Vault Item-Type Parity (Design Spec)
**Date:** 2026-06-20
**Status:** Approved (design) — implementation plan to follow
**Predecessor:** `docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md` (org vault shipped v0.8.0, `50b5c01`)
**Tracked-from:** the org-vault plan's deferral note — `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md:3839` and the v0.8.1 follow-up list at `:6132`.
## Goal
Bring `relicario org add` and `relicario org edit` to **full item-type parity** with the personal vault. Today the org surface supports only **Login, SecureNote, Identity**; this milestone adds **Card, Key, Document, Totp**.
Secrets are entered via **interactive prompts by default**, with **`--*-stdin` escape hatches** for non-interactive scripting and the acceptance tests — matching the personal vault's secret-input philosophy while staying fully testable.
Document support additionally requires org-side **attachment storage** and a **`relicario-server` pre-receive hook change** that grant-scopes attachment write paths (closing a latent authorization gap).
## Background — current state (verified)
| Surface | Coverage today | Mechanism |
|---|---|---|
| Personal `add` (`commands/add.rs`) | All 7 types | per-type `build_*_item`; flags + `prompt_secret`/stdin |
| Personal `edit` (`commands/edit.rs`) | All 7 types | interactive per-type, "blank to keep", **field history** (synthetic `core:<field>` FieldIds) |
| Org `add` (`commands/org.rs::build_org_item`) | Login / SecureNote / Identity | plain value flags only (incl. `--password`) |
| Org `edit` (`commands/org.rs::run_edit`) | Login / SecureNote / Identity | flat `Option<String>` flag args |
- **Org storage** is `items/<slug>/<id>.enc` only (`org_session.rs::item_path`). There is **no attachment support and no settings/caps** on the org side.
- **Hook** (`crates/relicario-server/src/lib.rs::classify_path`) classifies paths as `Protected` (members/collections/org.json), `Item { collection }` (`items/<slug>/<id>.enc` — grant-authorized), `Rejected`, or **`Unrestricted`** (everything else — gated only by the per-commit member-signature check). An `attachments/...` path therefore currently falls through to **`Unrestricted`**: any member could push attachment blobs regardless of collection grants. Document parity must close this.
Why the four types were deferred: their personal builders read secrets interactively (`prompt_secret` / multiline stdin), so they had no non-interactive path the org acceptance tests could drive. Document additionally has no org storage target.
## Design
### Approach
**Shared builder/edit module + parity on both surfaces.** Extract the per-type item construction, secret-resolution, and interactive-edit logic into one CLI module that *both* the personal commands and the org commands call. This eliminates the existing personal↔org builder duplication and prevents the two surfaces from drifting again. `--*-stdin` is added to **both** surfaces (true parity), not org-only.
Rejected alternatives: (B) duplicate org-specific builders in `org.rs` — smaller blast radius but locks in two diverging builder sets, which is exactly the drift this milestone is paying down; (C) push builders into `relicario-core` — overkill, since prompt/stdin/storage logic does not belong in the bytes-in/bytes-out core.
### 1. Shared item-build module — `crates/relicario-cli/src/commands/item_build.rs`
- **`SecretSource` resolution**: a helper that resolves a secret field in priority order — explicit flag value → `--*-stdin` (read a single line, or multiline-to-EOF for key material / note body) → interactive `prompt_secret`/`prompt`. If a required secret has no flag, no stdin flag, and the process is non-interactive (no TTY), it errors clearly rather than hanging.
- **Builders** `build_login`, `build_secure_note`, `build_identity`, `build_card`, `build_key`, `build_totp` → return a fully-populated `Item` (no storage side effects).
- **`build_document`** → returns `(Item, EncryptedAttachment)` so each caller writes the encrypted blob with *its own* master key and *its own* path layout (personal: `vault.root()/attachments/<item-id>/…`; org: `attachments/<slug>/<item-id>/…`).
- **Shared per-type interactive edit helpers** — mutate a `&mut ItemCore` slice in place and record field history via the existing synthetic-`FieldId` scheme (`commands/edit.rs::push_history`), reused by both personal and org edit.
- Personal `add.rs` / `edit.rs` are refactored to call these helpers with **no behavior change** (existing personal tests stay green), then gain `--*-stdin` flags.
### 2. CLI surface — `org add`
Extend `OrgAddKind` (in `main.rs`) with `card` / `key` / `document` / `totp` subcommands mirroring the personal `AddKind` flags, plus the org-required `--collection` and the secret-stdin flags:
- `org add card --collection <s> --title <t> [--holder <h>] [--expiry YYYY-MM] [--kind credit|debit|gift|loyalty|other] [--number-stdin] [--cvv-stdin] [--pin-stdin]` — secrets prompted when a TTY is present and no `--*-stdin` flag is set.
- `org add key --collection <s> --title <t> [--label <l>] [--algorithm <a>] [--public-key <p>] [--material-stdin]`
- `org add totp --collection <s> --title <t> [--issuer <i>] [--label <l>] [--period 30] [--digits 6] [--algorithm sha1] [--secret <b32> | --secret-stdin]`
- `org add document --collection <s> --title <t> --file <path>` — no secret; file bytes encrypted with the org key and written to the collection-scoped attachment path.
Retrofit `org add login` to accept `--password` / `--password-stdin` (+ prompt fallback) so the existing type matches the new convention. All paths flow through the shared builders and are committed via the existing signed `org_git_run` path with the same `Relicario-*` trailers as today's `run_add`.
### 3. CLI surface — `org edit`
Restructure `run_edit` to dispatch per item type (mirroring personal `edit`): interactive "blank to keep" by default, with flag / `--*-stdin` overrides for scripts and tests. Field history is recorded with the same synthetic-key scheme as personal edit. Document edit accepts an optional `--file` that re-encrypts and replaces the primary attachment (re-points `DocumentCore.primary_attachment` + `AttachmentRef`, stages old + new paths). Grant + collection-existence checks are unchanged.
### 4. Org attachment storage + cap
- **Layout:** `attachments/<slug>/<item-id>/<att-id>.enc` — collection-scoped, mirroring `items/<slug>/<id>.enc`.
- **`org_session` methods:** `attachment_path`, `save_attachment`, `load_attachment`, `remove_item_attachments` (purge removes an item's attachment directory).
- **Cap:** a **default constant** in the CLI org path (mirroring the personal-vault `attachment_caps` default; the spec/code cites the source line per the code-constant-pinning rule). Per-org configurable caps are out of scope for v0.8.1.
### 5. Hook change — `relicario-server`
- Extend `classify_path` (`lib.rs`) to recognize `attachments/<slug>/<item-id>/<att-id>.enc` and classify it as `PathClass::Item { collection: slug }` — reusing the existing grant + slug-existence authorization for items. Apply the same defenses as the `items/` branch: exact segment count and a `.`-free slug guard (path-traversal defense).
- This converts attachment writes from `Unrestricted` to grant-scoped, closing the gap.
- **Version bump** for `relicario-server`; the release notes must call out a **coordinated server redeploy** (the deployed pre-receive hook must be rebuilt) — Document writes to a not-yet-upgraded server still succeed but remain `Unrestricted` until the hook is updated.
### 6. Tests (acceptance)
- `crates/relicario-cli/tests/org_items.rs`: non-interactive add → get → edit → rm round-trips for **Card, Key, Totp, Document** driven through the `--*-stdin` flags; secret masking verified in `org get` without `--show`; a grant-denied attachment-write case.
- `crates/relicario-server` lib tests: `classify_path("attachments/eng/<id>/<att>.enc") == Item { collection: "eng" }`; rejection cases for malformed attachment paths.
- Existing personal `add`/`edit` tests stay green after the shared-module refactor (behavior-preserving).
- Green across all crates (`cargo test`).
### 7. Living-docs updates (per CLAUDE.md discipline)
- `docs/FORMATS.md` — org attachment path layout + the default cap constant (cite source line).
- `crates/relicario-cli/ARCHITECTURE.md` — the shared `item_build` module + per-type org `add`/`edit`.
- `docs/SECURITY.md` — attachment writes are now grant-scoped (closing the `Unrestricted` gap).
- `STATUS.md` / `ROADMAP.md` / `CHANGELOG.md` — on release; mark org item-type parity landed, move Document/attachment + hook change to shipped.
- Extension docs untouched — extension org **writes** remain deferred (Plan B-2).
## Out of scope (v0.8.1)
- Extension org **writes** (`Plan B-2`).
- Per-collection subkeys, read audit, SSO/SAML/LDAP, HTTP management plane (phase 2).
- Per-org **configurable** attachment cap (a default constant ships now).
## Suggested execution decomposition (for the plan)
Four parallel dev streams; Dev-A is the dependency gate for B and C, Dev-D is fully independent:
| Stream | Scope | Depends on |
|---|---|---|
| **Dev-A** | Shared `item_build` module (SecretSource, builders, shared edit helpers); refactor personal `add`/`edit`; add `--*-stdin` to personal CLI | — (foundation) |
| **Dev-B** | Org `add`/`edit` parity for **Card / Key / Totp**; secret-stdin flags; field history; `org_items` tests | Dev-A module interface |
| **Dev-C** | Org **Document** + attachment storage (`org_session` methods, default cap, doc add/edit via `--file`); Document tests | Dev-A (`build_document`) |
| **Dev-D** | `relicario-server` hook: `classify_path` attachment grant-scoping; server tests; version bump | — (independent) |
## Open questions
None blocking. The cap value and the exact `--*-stdin` flag spellings are finalized in the plan against the personal-vault source.