1mod audit_paths;
4mod conformance;
5mod signature;
6
7pub use audit_paths::{resolve_audit_jsonl_path, resolve_audit_jsonl_path_beside_manifest};
8pub use conformance::{ConformanceCorpus, CorpusError, ReplayError, ReplayOutcome};
9
10pub use signature::{
11 parse_signing_seed, sign_manifest, signing_digest, signing_payload, verify_package_signature,
12 verify_package_signature_trusted, verify_package_signature_with_anchor,
13 verify_signature_fields, SignatureTrust, TrustAnchor, ALGORITHM_ED25519, TRUSTED_KEYS_ENV,
14};
15
16use camino::Utf8PathBuf;
17use indexmap::IndexMap;
18use mcpact_core::{ApprovalMode, AuthorityClass, TrustCeiling};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::{fs, path::Path};
22use thiserror::Error;
23
24pub const SCHEMA_VERSION: &str = "mcpact.manifest.v1";
26
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29pub struct Manifest {
30 pub schema_version: String,
32 pub package: PackageSpec,
34 pub cli: CliSpec,
36 #[serde(default)]
38 pub audit: AuditSpec,
39 #[serde(default)]
41 pub tools: Vec<ToolSpec>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct PackageSpec {
47 pub name: String,
49 #[serde(default = "default_version")]
51 pub version: String,
52 #[serde(default)]
54 pub description: String,
55 #[serde(default = "default_trust_ceiling")]
57 pub trust: TrustCeiling,
58 #[serde(default)]
60 pub signature: Option<PackageSignature>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65pub struct PackageSignature {
66 pub algorithm: String,
68 pub public_key: String,
70 pub signature: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
76pub struct CliSpec {
77 pub name: String,
79 pub binary: String,
81 #[serde(default)]
83 pub version_command: Vec<String>,
84 #[serde(default)]
86 #[schemars(with = "Option<String>")]
87 pub default_cwd: Option<Utf8PathBuf>,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
92pub struct AuditSpec {
93 #[serde(default = "default_audit_sink")]
95 pub sink: String,
96 #[serde(default = "default_audit_path")]
98 pub path: String,
99 #[serde(default)]
101 pub xdg_state: bool,
102 #[serde(default)]
104 pub url: Option<String>,
105}
106
107impl AuditSpec {
108 pub fn validate_sink(&self, report: &mut ValidationReport) {
110 match self.sink.as_str() {
111 "jsonl" | "cloudevents" => {}
112 "otel" | "cortex" => {
113 report.errors.push(format!(
114 "audit.sink `{}` is not yet supported (see ADR-0027)",
115 self.sink
116 ));
117 }
118 other => report.errors.push(format!(
119 "unknown audit.sink `{other}` (expected jsonl or cloudevents)"
120 )),
121 }
122 }
123}
124
125impl Default for AuditSpec {
126 fn default() -> Self {
127 Self {
128 sink: default_audit_sink(),
129 path: default_audit_path(),
130 xdg_state: false,
131 url: None,
132 }
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
138pub struct ToolSpec {
139 pub name: String,
141 #[serde(default)]
143 pub title: Option<String>,
144 #[serde(default)]
146 pub description: String,
147 #[serde(default)]
149 pub command: Vec<String>,
150 #[serde(default)]
152 pub args: IndexMap<String, ArgSpec>,
153 #[serde(default)]
155 pub output: OutputSpec,
156 #[serde(default)]
158 pub policy: PolicySpec,
159 #[serde(default)]
161 pub env: EnvSpec,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
166pub struct ArgSpec {
167 #[serde(rename = "type")]
169 pub arg_type: ArgType,
170 #[serde(default)]
172 pub required: bool,
173 #[serde(default)]
175 pub description: String,
176 #[serde(default)]
178 pub flag: Option<String>,
179 #[serde(default)]
181 pub position: Option<u32>,
182 #[serde(default)]
184 pub values: Vec<String>,
185 #[serde(default)]
187 pub default: Option<serde_json::Value>,
188 #[serde(default)]
190 pub multiple: bool,
191 #[serde(default)]
193 pub must_exist: bool,
194 #[serde(default)]
196 pub within_workspace: bool,
197 #[serde(default)]
199 pub redact: bool,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204#[serde(rename_all = "kebab-case")]
205pub enum ArgType {
206 String,
208 Path,
210 Enum,
212 Bool,
214 Integer,
216 Number,
218 Json,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
224pub struct OutputSpec {
225 #[serde(default = "default_output_mode")]
227 pub mode: String,
228 #[serde(default = "default_output_cap")]
230 pub max_bytes: usize,
231 #[serde(default)]
233 pub expose_stderr: bool,
234}
235
236impl Default for OutputSpec {
237 fn default() -> Self {
238 Self {
239 mode: default_output_mode(),
240 max_bytes: default_output_cap(),
241 expose_stderr: false,
242 }
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
248pub struct PolicySpec {
249 #[serde(default = "default_authority")]
251 pub authority: AuthorityClass,
252 #[serde(default)]
254 pub read_only: bool,
255 #[serde(default)]
257 pub network: bool,
258 #[serde(default)]
260 pub writes_files: bool,
261 #[serde(default = "default_approval")]
263 pub approval: ApprovalMode,
264 #[serde(default)]
266 pub requires_approval: bool,
267 #[serde(default = "default_timeout")]
269 pub timeout_seconds: u64,
270}
271
272impl Default for PolicySpec {
273 fn default() -> Self {
274 Self {
275 authority: default_authority(),
276 read_only: false,
277 network: false,
278 writes_files: false,
279 approval: default_approval(),
280 requires_approval: false,
281 timeout_seconds: default_timeout(),
282 }
283 }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
288pub struct EnvSpec {
289 #[serde(default)]
291 pub inherit: bool,
292 #[serde(default)]
294 pub allow: Vec<String>,
295 #[serde(default)]
297 pub set: IndexMap<String, String>,
298}
299
300impl Default for EnvSpec {
301 fn default() -> Self {
302 Self {
303 inherit: false,
304 allow: Vec::new(),
305 set: IndexMap::new(),
306 }
307 }
308}
309
310#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
312pub struct ValidationReport {
313 pub errors: Vec<String>,
315 pub warnings: Vec<String>,
317}
318
319impl ValidationReport {
320 #[must_use]
322 pub fn is_ok(&self) -> bool {
323 self.errors.is_empty()
324 }
325}
326
327#[derive(Debug, Error)]
329pub enum ManifestError {
330 #[error("manifest I/O failed: {0}")]
332 Io(#[from] std::io::Error),
333 #[error("manifest parse failed: {0}")]
335 Toml(#[from] toml::de::Error),
336 #[error("manifest validation failed: {0:?}")]
338 Validation(Vec<String>),
339}
340
341impl Manifest {
342 pub fn load(path: impl AsRef<Path>) -> Result<Self, ManifestError> {
344 let text = fs::read_to_string(path)?;
345 Self::parse(&text)
346 }
347
348 pub fn parse(text: &str) -> Result<Self, ManifestError> {
350 let manifest: Self = toml::from_str(text)?;
351 manifest.validate_strict()?;
352 Ok(manifest)
353 }
354
355 #[must_use]
357 pub fn validate(&self) -> ValidationReport {
358 let mut report = ValidationReport::default();
359 if self.schema_version != SCHEMA_VERSION {
360 report
361 .errors
362 .push(format!("schema_version must be {SCHEMA_VERSION}"));
363 }
364 if self.package.name.trim().is_empty() {
365 report.errors.push("package.name is required".into());
366 }
367 if self.cli.binary.trim().is_empty() {
368 report.errors.push("cli.binary is required".into());
369 }
370 if self.tools.is_empty() {
371 report.errors.push("at least one tool is required".into());
372 }
373 for tool in &self.tools {
374 validate_tool(tool, &mut report);
375 }
376 self.audit.validate_sink(&mut report);
377 match self.package.trust {
378 TrustCeiling::Signed | TrustCeiling::Verified if self.package.signature.is_none() => {
379 report.errors.push(format!(
380 "package.trust {:?} requires package.signature",
381 self.package.trust
382 ));
383 }
384 _ => {}
385 }
386 if self.package.signature.is_some() {
387 if let Err(err) = verify_package_signature(self) {
388 report
389 .errors
390 .push(format!("package.signature invalid: {err}"));
391 }
392 }
393 report
394 }
395
396 pub fn validate_pack_layout(&self, path: impl AsRef<Path>, report: &mut ValidationReport) {
409 if self.package.trust != TrustCeiling::Verified {
410 return;
411 }
412 let base = path.as_ref().parent().unwrap_or_else(|| Path::new("."));
413 if !base.join("README.md").is_file() {
414 report
415 .errors
416 .push("Verified pack missing `README.md` beside manifest".into());
417 }
418 let corpus_path = base.join("tests/conformance.jsonl");
419 if !corpus_path.is_file() {
420 report
421 .errors
422 .push("Verified pack missing `tests/conformance.jsonl` beside manifest".into());
423 } else if let Err(err) = ConformanceCorpus::parse_file(&corpus_path) {
424 report.errors.push(format!(
425 "Verified pack `tests/conformance.jsonl` is not a replayable conformance corpus: {err}"
426 ));
427 }
428 }
429
430 pub fn validate_strict(&self) -> Result<(), ManifestError> {
432 let report = self.validate();
433 if report.is_ok() {
434 Ok(())
435 } else {
436 Err(ManifestError::Validation(report.errors))
437 }
438 }
439}
440
441fn validate_tool(tool: &ToolSpec, report: &mut ValidationReport) {
442 if !is_valid_tool_name(&tool.name) {
443 report.errors.push(format!(
444 "tool name `{}` must be snake_case ASCII",
445 tool.name
446 ));
447 }
448 if tool
449 .command
450 .iter()
451 .any(|a| a == "sh" || a == "bash" || a == "-c")
452 {
453 report.errors.push(format!(
454 "tool `{}` appears to request shell execution",
455 tool.name
456 ));
457 }
458 if tool.policy.authority.is_high_risk()
459 && !tool.policy.requires_approval
460 && tool.policy.approval == ApprovalMode::Never
461 {
462 report.errors.push(format!(
463 "tool `{}` has high-risk authority `{}` but no approval policy",
464 tool.name, tool.policy.authority
465 ));
466 }
467 if tool.policy.read_only && (tool.policy.writes_files || tool.policy.authority.is_high_risk()) {
468 report.warnings.push(format!(
469 "tool `{}` is read_only but declares writes/high-risk authority",
470 tool.name
471 ));
472 }
473 for (name, arg) in &tool.args {
474 if !is_valid_arg_name(name) {
475 report.errors.push(format!(
476 "tool `{}` arg `{}` has invalid name",
477 tool.name, name
478 ));
479 }
480 if matches!(arg.arg_type, ArgType::Enum) && arg.values.is_empty() {
481 report.errors.push(format!(
482 "tool `{}` enum arg `{}` has no values",
483 tool.name, name
484 ));
485 }
486 if arg.flag.as_deref().is_some_and(|f| !f.starts_with('-')) {
487 report.errors.push(format!(
488 "tool `{}` arg `{}` flag must start with -",
489 tool.name, name
490 ));
491 }
492 }
493}
494
495#[must_use]
497pub fn is_valid_tool_name(name: &str) -> bool {
498 !name.is_empty()
499 && name
500 .chars()
501 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
502 && name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
503}
504
505#[must_use]
507pub fn is_valid_arg_name(name: &str) -> bool {
508 is_valid_tool_name(name)
509}
510
511fn default_version() -> String {
512 "0.1.0".into()
513}
514fn default_trust_ceiling() -> TrustCeiling {
515 TrustCeiling::Reviewed
516}
517fn default_audit_sink() -> String {
518 "jsonl".into()
519}
520fn default_audit_path() -> String {
521 ".mcpact/audit.jsonl".into()
522}
523fn default_output_mode() -> String {
524 "text".into()
525}
526fn default_output_cap() -> usize {
527 1_048_576
528}
529fn default_authority() -> AuthorityClass {
530 AuthorityClass::Observe
531}
532fn default_approval() -> ApprovalMode {
533 ApprovalMode::Never
534}
535fn default_timeout() -> u64 {
536 60
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use std::path::PathBuf;
543
544 const VALID: &str = r#"
545schema_version = "mcpact.manifest.v1"
546
547[package]
548name = "mcp-taudit"
549trust = "Reviewed"
550
551[cli]
552name = "taudit"
553binary = "taudit"
554version_command = ["--version"]
555
556[[tools]]
557name = "scan_pipeline"
558description = "scan a pipeline"
559command = ["scan"]
560
561[tools.args.path]
562type = "path"
563required = true
564position = 1
565must_exist = true
566within_workspace = true
567
568[tools.args.format]
569type = "enum"
570required = false
571flag = "--format"
572values = ["json", "sarif"]
573default = "json"
574
575[tools.output]
576mode = "json"
577max_bytes = 1048576
578
579[tools.policy]
580authority = "Analyze"
581read_only = true
582network = false
583writes_files = false
584approval = "never"
585timeout_seconds = 60
586"#;
587
588 #[test]
589 fn parses_valid_manifest() {
590 let manifest = Manifest::parse(VALID).unwrap();
591 assert_eq!(manifest.tools[0].name, "scan_pipeline");
592 assert!(manifest.validate().is_ok());
593 }
594
595 #[test]
596 fn rejects_high_risk_without_approval() {
597 let text = VALID.replace("Analyze", "Destroy");
598 let err = Manifest::parse(&text).unwrap_err();
599 assert!(format!("{err}").contains("high-risk"));
600 }
601
602 #[test]
603 fn rejects_shell_in_command() {
604 let text = VALID.replace("command = [\"scan\"]", "command = [\"sh\", \"-c\"]");
605 let err = Manifest::parse(&text).unwrap_err();
606 assert!(format!("{err}").contains("shell"));
607 }
608
609 #[test]
610 fn rejects_wrong_schema_version() {
611 let text = VALID.replace("mcpact.manifest.v1", "mcpact.manifest.v0");
612 let err = Manifest::parse(&text).unwrap_err();
613 assert!(format!("{err}").contains("schema_version"));
614 }
615
616 #[test]
617 fn rejects_invalid_tool_name() {
618 let mut manifest = Manifest::parse(VALID).unwrap();
619 manifest.tools[0].name = "Bad-Name".into();
620 assert!(!manifest.validate().is_ok());
621 }
622
623 #[test]
624 fn rejects_enum_without_values() {
625 let text = VALID.replace("values = [\"json\", \"sarif\"]", "values = []");
626 let err = Manifest::parse(&text).unwrap_err();
627 assert!(format!("{err}").contains("enum"));
628 }
629
630 #[test]
631 fn all_example_adapters_validate() {
632 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/adapters");
633 for name in [
634 "taudit",
635 "cargo",
636 "gh-readonly",
637 "terraform-plan",
638 "fixture-echo",
639 "signed-fixture-echo",
640 ] {
641 let path = root.join(name).join("mcpact.toml");
642 let manifest = Manifest::load(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
643 assert!(manifest.validate().is_ok(), "{name} manifest invalid");
644 }
645 }
646
647 #[test]
659 fn env_spec_default_does_not_inherit_env() {
660 let env = EnvSpec::default();
662 assert!(
663 !env.inherit,
664 "EnvSpec::default().inherit MUST be false; a regression would silently re-enable env inheritance"
665 );
666 assert!(
667 env.allow.is_empty(),
668 "EnvSpec::default().allow MUST be empty so allow-list path is also a no-op"
669 );
670 assert!(env.set.is_empty(), "EnvSpec::default().set MUST be empty");
671 }
672
673 #[test]
674 fn env_spec_inherit_default_in_serde_is_false() {
675 let m = Manifest::parse(VALID).unwrap();
678 assert!(
679 !m.tools[0].env.inherit,
680 "tool with no [tools.env] block should default to inherit=false"
681 );
682 }
683
684 fn scratch_dir(tag: &str) -> PathBuf {
691 let dir = std::env::temp_dir().join(format!(
692 "mcpact-layout-test-{tag}-{}-{:?}",
693 std::process::id(),
694 std::time::SystemTime::now()
695 .duration_since(std::time::UNIX_EPOCH)
696 .unwrap()
697 .as_nanos()
698 ));
699 fs::create_dir_all(dir.join("tests")).unwrap();
700 dir
701 }
702
703 fn verified_manifest() -> Manifest {
704 let mut m = Manifest::parse(VALID).unwrap();
705 m.package.trust = TrustCeiling::Verified;
706 m
707 }
708
709 const GOOD_CORPUS: &str = concat!(
710 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
711 "\n",
712 r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"scan_pipeline"}}"#,
713 );
714
715 #[test]
716 fn verified_layout_passes_with_valid_corpus() {
717 let dir = scratch_dir("good");
718 fs::write(dir.join("README.md"), "# pack").unwrap();
719 fs::write(dir.join("tests/conformance.jsonl"), GOOD_CORPUS).unwrap();
720 let manifest_path = dir.join("mcpact.toml");
721
722 let mut report = ValidationReport::default();
723 verified_manifest().validate_pack_layout(&manifest_path, &mut report);
724 assert!(
725 report.is_ok(),
726 "valid verified layout should pass: {:?}",
727 report.errors
728 );
729 let _ = fs::remove_dir_all(&dir);
730 }
731
732 #[test]
733 fn verified_layout_fails_closed_on_empty_corpus() {
734 let dir = scratch_dir("empty");
735 fs::write(dir.join("README.md"), "# pack").unwrap();
736 fs::write(dir.join("tests/conformance.jsonl"), "\n \n").unwrap();
738 let manifest_path = dir.join("mcpact.toml");
739
740 let mut report = ValidationReport::default();
741 verified_manifest().validate_pack_layout(&manifest_path, &mut report);
742 assert!(
743 !report.is_ok(),
744 "an empty conformance corpus must NOT satisfy Verified"
745 );
746 assert!(
747 report.errors.iter().any(|e| e.contains("replayable")),
748 "error should explain the corpus is not replayable: {:?}",
749 report.errors
750 );
751 let _ = fs::remove_dir_all(&dir);
752 }
753
754 #[test]
755 fn verified_layout_fails_closed_on_malformed_corpus() {
756 let dir = scratch_dir("malformed");
757 fs::write(dir.join("README.md"), "# pack").unwrap();
758 fs::write(dir.join("tests/conformance.jsonl"), "this is not json\n").unwrap();
760 let manifest_path = dir.join("mcpact.toml");
761
762 let mut report = ValidationReport::default();
763 verified_manifest().validate_pack_layout(&manifest_path, &mut report);
764 assert!(
765 !report.is_ok(),
766 "a malformed conformance corpus must NOT satisfy Verified"
767 );
768 let _ = fs::remove_dir_all(&dir);
769 }
770}
771
772#[cfg(test)]
773mod proptest_roundtrip {
774 use super::*;
775 use proptest::prelude::*;
776
777 proptest! {
778 #[test]
779 fn audit_spec_toml_roundtrip(
780 path in r"(\.?/?[a-zA-Z0-9_./-]{0,48})",
781 xdg in any::<bool>(),
782 sink in prop_oneof!["jsonl", "cloudevents"],
783 ) {
784 let spec = AuditSpec {
785 sink: sink.to_string(),
786 path,
787 xdg_state: xdg,
788 url: None,
789 };
790 let encoded = toml::to_string(&spec).expect("encode audit spec");
791 let decoded: AuditSpec = toml::from_str(&encoded).expect("decode audit spec");
792 prop_assert_eq!(spec, decoded);
793 }
794
795 #[test]
796 fn manifest_json_roundtrip(name in r"[a-z][a-z0-9_]{2,24}") {
797 let manifest = Manifest {
798 schema_version: SCHEMA_VERSION.into(),
799 package: PackageSpec {
800 name: format!("mcp-{name}"),
801 version: "0.1.0".into(),
802 description: "proptest".into(),
803 trust: TrustCeiling::Reviewed,
804 signature: None,
805 },
806 cli: CliSpec {
807 name: name.clone(),
808 binary: name,
809 version_command: vec!["--version".into()],
810 default_cwd: None,
811 },
812 tools: vec![ToolSpec {
813 name: "probe_tool".into(),
814 title: None,
815 description: String::new(),
816 command: vec!["scan".into()],
817 args: IndexMap::new(),
818 output: OutputSpec::default(),
819 policy: PolicySpec {
820 authority: AuthorityClass::Observe,
821 read_only: true,
822 network: false,
823 writes_files: false,
824 approval: ApprovalMode::Never,
825 requires_approval: false,
826 timeout_seconds: 30,
827 },
828 env: EnvSpec::default(),
829 }],
830 audit: AuditSpec::default(),
831 };
832 let json = serde_json::to_string(&manifest).expect("serialize");
833 let back: Manifest = serde_json::from_str(&json).expect("deserialize");
834 prop_assert_eq!(&manifest.package.name, &back.package.name);
835 prop_assert_eq!(&manifest.tools[0].name, &back.tools[0].name);
836 }
837 }
838}