systemprompt_cli/commands/core/services/
inspect.rs1use std::path::PathBuf;
8
9use anyhow::{Context, Result, bail};
10use clap::Args;
11use serde::Serialize;
12use systemprompt_config::ProfileBootstrap;
13use systemprompt_loader::ServicesRootBootstrap;
14use systemprompt_loader::bundle::{BundleCache, cache_root, verify};
15use systemprompt_manifest::Profile;
16use systemprompt_manifest::services::bundle::SignedBundleManifest;
17use systemprompt_security::manifest_signing::{canonical_manifest_bytes, verify_with_pubkey};
18
19use crate::shared::CommandOutput;
20
21#[derive(Debug, Clone, Args)]
22pub struct InspectArgs {
23 #[arg(long, help = "Bundle archive to inspect")]
24 pub bundle: Option<PathBuf>,
25
26 #[arg(
27 long,
28 conflicts_with = "bundle",
29 help = "Inspect the composition this instance is running"
30 )]
31 pub active: bool,
32}
33
34#[derive(Debug, Serialize)]
35pub struct BundleReport {
36 pub version: String,
37 pub created_at: String,
38 pub requires_core: String,
39 pub content_hash: String,
40 pub source_repo: Option<String>,
41 pub source_commit: Option<String>,
42 pub workflow_run: Option<String>,
43 pub files: usize,
44 pub total_size: u64,
45 pub owns: String,
46 pub signature_key_id: Option<String>,
47 pub signature_status: String,
48}
49
50#[derive(Debug, Serialize)]
51pub struct ActiveReport {
52 pub path: String,
53 pub provenance: String,
54 pub composed_hash: Option<String>,
55 pub last_reconciled_hash: Option<String>,
56 pub sources: String,
57 pub detail: Option<String>,
58}
59
60pub fn execute(args: &InspectArgs) -> Result<CommandOutput> {
61 if let Some(archive) = args.bundle.as_deref() {
62 let signed = verify::read_manifest(archive)
63 .with_context(|| format!("Failed to read {}", archive.display()))?;
64 let report = describe_bundle(&signed, ProfileBootstrap::get().ok());
65 return Ok(CommandOutput::card_value("Services Bundle", &report));
66 }
67 if !args.active {
68 bail!("Pass --bundle <archive> or --active");
69 }
70 let profile = ProfileBootstrap::get().context("Failed to get profile")?;
71 Ok(CommandOutput::card_value(
72 "Active Services Root",
73 &describe_active(profile)?,
74 ))
75}
76
77#[must_use]
78pub fn describe_bundle(signed: &SignedBundleManifest, profile: Option<&Profile>) -> BundleReport {
79 let manifest = &signed.manifest;
80 BundleReport {
81 version: manifest.version.clone(),
82 created_at: manifest.created_at.to_rfc3339(),
83 requires_core: manifest.requires_core.clone(),
84 content_hash: manifest.content_hash.clone(),
85 source_repo: manifest.source.repo.clone(),
86 source_commit: manifest.source.commit.clone(),
87 workflow_run: manifest.source.workflow_run.clone(),
88 files: manifest.files.len(),
89 total_size: manifest.total_size,
90 owns: owns_summary(manifest),
91 signature_key_id: signed.signature.as_ref().map(|s| s.key_id.clone()),
92 signature_status: signature_status(signed, profile),
93 }
94}
95
96fn owns_summary(
97 manifest: &systemprompt_manifest::services::bundle::ServicesBundleManifest,
98) -> String {
99 let owns = &manifest.owns;
100 format!(
101 "{} marketplace(s), {} plugin(s), {} skill(s), {} rule(s), {} hook(s), {} artifact(s); \
102 dirs: {}",
103 owns.marketplaces.len(),
104 owns.plugins.len(),
105 owns.skills.len(),
106 owns.rules.len(),
107 owns.hooks.len(),
108 owns.artifacts.len(),
109 owns.dirs.join(", ")
110 )
111}
112
113fn signature_status(signed: &SignedBundleManifest, profile: Option<&Profile>) -> String {
114 let Some(signature) = signed.signature.as_ref() else {
115 return "unsigned".to_owned();
116 };
117 let Some(profile) = profile else {
118 return "signed (no profile to check pinned keys against)".to_owned();
119 };
120 let pinned: Vec<&String> = profile
121 .services
122 .sources
123 .iter()
124 .filter_map(|source| source.verification())
125 .flat_map(|verification| verification.ed25519_public_keys.iter())
126 .collect();
127 if pinned.is_empty() {
128 return "signed (profile pins no keys)".to_owned();
129 }
130 let Ok(payload) = canonical_manifest_bytes(&signed.manifest) else {
131 return "signed (manifest could not be canonicalised)".to_owned();
132 };
133 let verified = pinned
134 .iter()
135 .any(|key| verify_with_pubkey(key, &payload, &signature.sig_b64).is_ok());
136 if verified {
137 "verified against a pinned key".to_owned()
138 } else {
139 "signed by a key this profile does not pin".to_owned()
140 }
141}
142
143fn describe_active(profile: &Profile) -> Result<ActiveReport> {
144 let root = ServicesRootBootstrap::get()
145 .context("The services root has not been resolved in this process")?;
146 let state = BundleCache::new(cache_root(profile)).read_state();
147 let sources = state
148 .sources
149 .iter()
150 .map(|(name, s)| format!("{name}={} ({})", s.version, s.digest))
151 .collect::<Vec<_>>()
152 .join(", ");
153
154 let (provenance, composed_hash, detail) = match &root.provenance {
155 systemprompt_loader::ServicesProvenance::Bundled => ("bundled", None, None),
156 systemprompt_loader::ServicesProvenance::Fetched { composed_hash, .. } => {
157 ("fetched", Some(composed_hash.clone()), None)
158 },
159 systemprompt_loader::ServicesProvenance::LastGood {
160 composed_hash,
161 error,
162 } => (
163 "last_good",
164 Some(composed_hash.clone()),
165 Some(error.clone()),
166 ),
167 systemprompt_loader::ServicesProvenance::BundledFallback { error } => {
168 ("bundled_fallback", None, Some(error.clone()))
169 },
170 };
171
172 Ok(ActiveReport {
173 path: root.path.display().to_string(),
174 provenance: provenance.to_owned(),
175 composed_hash,
176 last_reconciled_hash: state.last_reconciled_hash,
177 sources,
178 detail,
179 })
180}