Skip to main content

systemprompt_cli/commands/core/services/
inspect.rs

1//! `services inspect` — bundle provenance for an archive or the active
2//! composition.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use 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}