majordomus_cli/capability/builtin/
pack.rs1use std::path::{Component, Path, PathBuf};
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
25use crate::capability::handler::{CapabilityError, Context};
26use crate::capability::model::{CliExposure, Exposure, Stability};
27use crate::capability::module::ModuleDescriptor;
28use crate::pack::{PackPlan, PackVerdict, Planned, Profiles};
29use crate::{capability, module};
30
31use super::{get, mcp};
32
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct PackPlanInput {
44 #[serde(default)]
46 pub profile: Option<String>,
47}
48
49impl BenchmarkCases for PackPlanInput {
50 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
51 vec![
52 NamedCase::new("default-profile", PackPlanInput { profile: None }),
53 NamedCase::new(
54 "chatgpt",
55 PackPlanInput {
56 profile: Some("chatgpt".into()),
57 },
58 ),
59 ]
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72#[serde(deny_unknown_fields)]
73pub struct PackVerifyInput {
74 pub dir: String,
77}
78
79impl BenchmarkCases for PackVerifyInput {
80 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
81 vec![NamedCase::new(
84 "absent",
85 PackVerifyInput {
86 dir: "tmp/packs/absent".into(),
87 },
88 )]
89 }
90}
91
92fn share_dir(ctx: &Context, root: &Path) -> Result<PathBuf, CapabilityError> {
93 crate::share::Share::locate(ctx.index.share.as_deref(), root)
94 .map(|s| s.dir().to_path_buf())
95 .map_err(|e| CapabilityError::Internal(e.to_string()))
96}
97
98pub(crate) fn planned(ctx: &Context, profile: Option<&str>) -> Planned {
103 let root = PathBuf::from(&ctx.index.repository.root);
104 match share_dir(ctx, &root) {
105 Ok(share) => crate::pack::plan(&root, &share, profile),
106 Err(e) => crate::pack::unmeasured(profile.unwrap_or(""), e.to_string()),
107 }
108}
109
110fn plan(ctx: &Context, input: PackPlanInput) -> Result<PackPlan, CapabilityError> {
111 Ok(planned(ctx, input.profile.as_deref()).plan)
112}
113
114fn inside(root: &Path, dir: &str) -> Result<PathBuf, CapabilityError> {
116 let p = Path::new(dir);
117 let rel = if p.is_absolute() {
118 p.strip_prefix(root)
119 .map_err(|_| CapabilityError::Refused(format!("{dir} is not inside the repository")))?
120 .to_path_buf()
121 } else {
122 p.to_path_buf()
123 };
124 if rel
125 .components()
126 .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
127 {
128 return Err(CapabilityError::Refused(format!(
129 "{dir} leaves the repository; a pack is verified where it was built"
130 )));
131 }
132 Ok(root.join(rel))
133}
134
135fn verify(ctx: &Context, input: PackVerifyInput) -> Result<PackVerdict, CapabilityError> {
136 let root = PathBuf::from(&ctx.index.repository.root);
137 let dir = inside(&root, &input.dir)?;
138 let share = share_dir(ctx, &root)?;
139 let profiles = Profiles::load(&share, &root).map_err(CapabilityError::Internal)?;
140 let mut v = crate::pack::verify(&dir, &profiles, &root);
141 v.dir = input.dir;
142 Ok(v)
143}
144
145pub fn module() -> ModuleDescriptor {
160 module! {
161 id: "pack",
162 title: "Source pack",
163 description: "The tracked tree as a few token-bounded Markdown shards a language model's file search can index, with an index that orients the reader and a manifest that proves afterwards what left the machine: the git index's blobs only, never the working tree, and never a binary, a gitlink or worktree, a symbolic link, build output or a credential, whatever the index holds.",
164 stability: Stability::BehaviorallyVerified,
165 capabilities: [
166 capability! {
167 id: "pack.plan",
168 title: "Plan a source pack",
169 description: "What a profile of share/archive.yaml would pack: the files carried and their bytes and o200k_base tokens, every file left out by reason (worktree, link, artifact, binary, derived, excluded; derived counted, the rest listed), the shards it is cut into within the profile's token budget and file count, and every finding that refuses the build — a file over the budget, too many shards, a machine path or credential in a selected file, an empty selection, a profile without limits. Reads git's index and blobs only; writes nothing.",
170 input: PackPlanInput,
171 output: PackPlan,
172 stability: Stability::BehaviorallyVerified,
173 exposure: Exposure {
174 mcp: mcp("majordomus_pack_plan"),
175 http: get("/api/v1/pack/plan"),
176 cli: Some(CliExposure { path: vec!["pack".into(), "plan".into()] }),
177 },
178 tags: ["pack", "archive", "review", "privacy"],
179 handler: plan,
180 },
181 capability! {
182 id: "pack.verify",
183 title: "Verify a written source pack",
184 description: "Read a pack directory inside the repository against its pack.json: every file it names is present with its digest and nothing else is, every carried file's content has the digest its marker records, no carried path is a binary, an artifact, a link or a gitlink under the profile the manifest names, no file is over the profile's token budget or holds a NUL byte or a leak, and there are no more files than the profile allows. An unreadable manifest is unmeasured, never a pass.",
185 input: PackVerifyInput,
186 output: PackVerdict,
187 stability: Stability::BehaviorallyVerified,
188 exposure: Exposure {
189 mcp: mcp("majordomus_pack_verify"),
190 http: get("/api/v1/pack/verify"),
191 cli: Some(CliExposure { path: vec!["pack".into(), "verify".into()] }),
192 },
193 tags: ["pack", "archive", "privacy", "evidence"],
194 handler: verify,
195 },
196 ],
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn a_verify_input_never_leaves_the_repository() {
206 let root = Path::new("/r/repo");
207 assert_eq!(
208 inside(root, "tmp/packs/a").unwrap(),
209 root.join("tmp/packs/a")
210 );
211 assert_eq!(inside(root, "/r/repo/tmp/p").unwrap(), root.join("tmp/p"));
212 assert!(inside(root, "../other").is_err());
213 assert!(inside(root, "tmp/../../x").is_err());
214 assert!(inside(root, "/etc").is_err());
215 }
216
217 #[test]
220 fn a_distribution_that_cannot_be_found_is_unmeasured_and_verifies_nothing() {
221 let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
222 let built = repo.context().expect("a context");
223 let mut index = repo.index().expect("an index");
224 index.share = Some(PathBuf::from("/nonexistent/share"));
225 let ctx = Context::new(std::sync::Arc::new(index), built.registry.clone());
226
227 let p = plan(&ctx, PackPlanInput::default()).expect("answered");
228 assert!(!p.measured && !p.passes, "{p:?}");
229 assert!(p.reason.is_some_and(|r| r.contains("/nonexistent/share")));
230
231 let input = PackVerifyInput {
232 dir: "tmp/packs/x".into(),
233 };
234 let e = verify(&ctx, input).expect_err("refused");
235 assert!(matches!(e, CapabilityError::Internal(_)), "{e}");
236 }
237
238 #[test]
239 fn the_declaration_yields_the_projections_it_claims() {
240 let m = module();
241 let plan = &m.capabilities[0].capability.exposure;
242 assert_eq!(plan.http.as_ref().unwrap().path, "/api/v1/pack/plan");
243 assert_eq!(plan.cli.as_ref().unwrap().path, ["pack", "plan"]);
244 let verify = &m.capabilities[1].capability.exposure;
245 assert_eq!(
246 verify.mcp.as_ref().and_then(|m| m.tool.as_deref()),
247 Some("majordomus_pack_verify")
248 );
249 }
250}