Skip to content

Commit eb019c2

Browse files
trible team: show <cap> — chain-walk diagnostic for a single capability
Added a `team show --pile P --cap HEX` subcommand that walks the chain of one capability and prints each level. Complements `team list` (per-cap one-line summaries) by exposing the full vertical slice for a single chain. The use case: an operator sees a connection rejected, runs `pile net status` to confirm their side, then `team show <their-cap>` to see what the cap actually carries. Output: level 0: a9f8aa73 → eb5e5a1d scope: PERM_ADMIN expires: 2026-05-28 16:00:11 UTC sig blob: dca595d6... cap blob: bbbbc92e... signer matches cap_issuer: ✓ ↳ root link (no cap_parent — signer should be team root) For the leaf-link (depth 0) the implementation is complete and catches the structural-mismatch case (sig signer ≠ cap_issuer). For length>1 chains, the walk currently stops at depth 0 and notes that the parent's sig is embedded; a future iteration could reconstruct the parent sig from the embedded sub-entity and continue. Comment in run_show explains the trade-off. INVENTORY entry "team show deep-dive" remains since the deep walk is future work; this lands the leaf-level diagnostic which is what 90% of "why is this cap rejected" debugging needs. Existing team_e2e tests still pass (3/3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a836538 commit eb019c2

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

trible/src/cli/team.rs

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,22 @@ pub enum Command {
9797
#[arg(long)]
9898
pile: PathBuf,
9999
},
100+
/// Walk the chain of one capability and print each level
101+
/// (subject, issuer, scope, expiry). Diagnostic deep-dive
102+
/// for "why is this cap rejected" — `team list` gives
103+
/// summaries, `team show` gives a single chain's full
104+
/// vertical slice. No cryptographic verification — use
105+
/// the relay's `OP_AUTH` round-trip via `pile net sync`
106+
/// for that.
107+
Show {
108+
/// Path to the local pile file.
109+
#[arg(long)]
110+
pile: PathBuf,
111+
/// Capability sig handle (hex, 32 bytes / 64 chars).
112+
/// The leaf to start the walk from.
113+
#[arg(long)]
114+
cap: String,
115+
},
100116
}
101117

102118
#[derive(Clone, Copy, clap::ValueEnum)]
@@ -134,6 +150,7 @@ pub fn run(cmd: Command) -> Result<()> {
134150
target,
135151
} => run_revoke(pile, team_root_secret, target),
136152
Command::List { pile } => run_list(pile),
153+
Command::Show { pile, cap } => run_show(pile, cap),
137154
}
138155
}
139156

@@ -684,3 +701,195 @@ fn run_list(pile_path: PathBuf) -> Result<()> {
684701
}
685702
Ok(())
686703
}
704+
705+
fn run_show(pile_path: PathBuf, cap_hex: String) -> Result<()> {
706+
use triblespace_core::blob::TryFromBlob;
707+
use triblespace_core::macros::pattern;
708+
use triblespace_core::query::find;
709+
use triblespace_core::repo::BlobStore;
710+
use triblespace_core::repo::BlobStoreGet;
711+
712+
let mut pile = open_pile(&pile_path)?;
713+
let leaf_sig = parse_handle_hex(&cap_hex)?;
714+
let reader = pile
715+
.reader()
716+
.map_err(|e| anyhow!("pile reader: {e:?}"))?;
717+
718+
// Walk the chain. At each step we're holding a sig handle —
719+
// load the sig blob, find what it signs (the cap blob's
720+
// handle), load the cap blob, decode subject/issuer/scope/
721+
// expiry, print, then if cap_parent is present chase it.
722+
let current_sig: Value<Handle<Blake3, SimpleArchive>> = leaf_sig;
723+
let depth = 0usize;
724+
725+
loop {
726+
let sig_blob: Blob<SimpleArchive> = reader
727+
.get::<Blob<SimpleArchive>, SimpleArchive>(current_sig)
728+
.map_err(|e| anyhow!("fetch sig blob {}: {e:?}", hex::encode(current_sig.raw)))?;
729+
let sig_set: TribleSet = TryFromBlob::try_from_blob(sig_blob)
730+
.map_err(|e| anyhow!("parse sig blob: {e:?}"))?;
731+
let mut sig_iter = find!(
732+
(
733+
sig: Id,
734+
signed: Value<Handle<Blake3, SimpleArchive>>,
735+
signer: VerifyingKey
736+
),
737+
pattern!(&sig_set, [{
738+
?sig @
739+
capability::sig_signs: ?signed,
740+
triblespace_core::repo::signed_by: ?signer,
741+
}])
742+
);
743+
let (_, cap_handle, signer) = match (sig_iter.next(), sig_iter.next()) {
744+
(Some(row), None) => row,
745+
_ => return Err(anyhow!("malformed sig blob — expected exactly one (sig_signs, signed_by) tuple")),
746+
};
747+
748+
let cap_blob: Blob<SimpleArchive> = reader
749+
.get::<Blob<SimpleArchive>, SimpleArchive>(cap_handle)
750+
.map_err(|e| anyhow!("fetch cap blob {}: {e:?}", hex::encode(cap_handle.raw)))?;
751+
let cap_set: TribleSet = TryFromBlob::try_from_blob(cap_blob)
752+
.map_err(|e| anyhow!("parse cap blob: {e:?}"))?;
753+
let mut cap_iter = find!(
754+
(
755+
e: Id,
756+
subject: VerifyingKey,
757+
issuer: VerifyingKey,
758+
root: Id,
759+
exp: Value<triblespace_core::value::schemas::time::NsTAIInterval>
760+
),
761+
pattern!(&cap_set, [{
762+
?e @
763+
capability::cap_subject: ?subject,
764+
capability::cap_issuer: ?issuer,
765+
capability::cap_scope_root: ?root,
766+
triblespace_core::metadata::expires_at: ?exp,
767+
}])
768+
);
769+
let (_, subject, issuer, scope_root, expiry) = match (cap_iter.next(), cap_iter.next()) {
770+
(Some(row), None) => row,
771+
_ => return Err(anyhow!("malformed cap blob — expected exactly one (subject, issuer, scope_root, expires_at) tuple")),
772+
};
773+
774+
// Permissions hung off the scope root.
775+
let perms: Vec<Id> = find!(
776+
(perm: Id),
777+
pattern!(&cap_set, [{
778+
scope_root @ triblespace_core::metadata::tag: ?perm
779+
}])
780+
)
781+
.map(|(p,)| p)
782+
.collect();
783+
let branches: Vec<Id> = find!(
784+
(b: Id),
785+
pattern!(&cap_set, [{
786+
scope_root @ capability::scope_branch: ?b
787+
}])
788+
)
789+
.map(|(b,)| b)
790+
.collect();
791+
792+
let perm_str = if perms.is_empty() {
793+
"no perms".to_string()
794+
} else {
795+
perms.iter().map(perm_label).collect::<Vec<_>>().join("|")
796+
};
797+
let branch_str = if branches.is_empty() {
798+
String::new()
799+
} else {
800+
let mut bs: Vec<String> = branches
801+
.iter()
802+
.map(|b| {
803+
let bytes: [u8; 16] = (*b).into();
804+
hex::encode(&bytes[..4])
805+
})
806+
.collect();
807+
bs.sort();
808+
format!(", branches=[{}]", bs.join(","))
809+
};
810+
let signer_matches_issuer = if signer == issuer { "✓" } else { "✗ MISMATCH" };
811+
812+
println!(
813+
"level {depth}: {} → {}",
814+
hex::encode(&issuer.to_bytes()[..4]),
815+
hex::encode(&subject.to_bytes()[..4]),
816+
);
817+
println!(" scope: {perm_str}{branch_str}");
818+
println!(" expires: {}", format_expiry(&expiry));
819+
println!(" sig blob: {}", hex::encode(current_sig.raw));
820+
println!(" cap blob: {}", hex::encode(cap_handle.raw));
821+
println!(" signer matches cap_issuer: {signer_matches_issuer}");
822+
823+
// Find parent cap_parent + cap_embedded_parent_sig handles
824+
// to walk one level up. If absent, this is the root link.
825+
let parent_pair = find!(
826+
(
827+
e: Id,
828+
parent_cap: Value<Handle<Blake3, SimpleArchive>>,
829+
parent_sig_id: Id,
830+
),
831+
pattern!(&cap_set, [{
832+
?e @
833+
capability::cap_parent: ?parent_cap,
834+
capability::cap_embedded_parent_sig: ?parent_sig_id,
835+
}])
836+
)
837+
.next();
838+
839+
match parent_pair {
840+
None => {
841+
println!(" ↳ root link (no cap_parent — signer should be team root)");
842+
println!();
843+
break;
844+
}
845+
Some((_, _parent_cap, parent_sig_id)) => {
846+
// The parent sig lives embedded inside this cap
847+
// blob as a sub-entity. Reconstruct its signed
848+
// handle by querying for the sub-entity.
849+
let mut iter = find!(
850+
(h: Value<Handle<Blake3, SimpleArchive>>),
851+
pattern!(&cap_set, [{
852+
parent_sig_id @ capability::sig_signs: ?h
853+
}])
854+
);
855+
let next_sig_handle = match iter.next() {
856+
Some((h,)) => h,
857+
None => {
858+
println!(" ⚠ embedded parent sig missing sig_signs — chain broken");
859+
println!();
860+
break;
861+
}
862+
};
863+
println!(" ↳ chained from parent (embedded sig)");
864+
println!();
865+
// For the next iteration we'd want the parent's
866+
// SIG blob. The embedded sig IS the parent's sig;
867+
// we already have its content via the sub-entity
868+
// queries. Use cap_handle (parent) as the new
869+
// "signed", and recurse manually by jumping to
870+
// the parent cap. This is structurally awkward
871+
// because our loop is sig-driven; simpler to
872+
// bail at this depth and tell the user to run
873+
// `team show` again with the parent cap's sig
874+
// handle if they minted it as a top-level entry.
875+
//
876+
// For now we surface what we know and stop.
877+
// A future iteration could reconstruct the
878+
// parent sig blob from the embedded sub-entity
879+
// and continue the walk. Keep the simple path
880+
// first — the leaf-link information is what
881+
// operators most often need.
882+
let _ = next_sig_handle;
883+
let _ = current_sig;
884+
break;
885+
}
886+
}
887+
#[allow(unreachable_code)]
888+
{
889+
depth += 1;
890+
}
891+
}
892+
893+
let _ = pile.close();
894+
Ok(())
895+
}

0 commit comments

Comments
 (0)