1mod toml;
56
57pub mod evaluator;
60
61pub mod standards;
65
66pub mod resolution;
72
73pub mod projection;
79
80use crate::gate::{ArtefactRef, Gate, GateKind, GateOutcome};
81use crate::types::{MissionConfig, Role};
82use std::collections::HashSet;
83use std::path::{Component, Path, PathBuf};
84
85pub const PACK_MANIFEST: &str = "pack.toml";
87
88pub const SCHEMA_BASE: u32 = 2;
92
93pub const SCHEMA_CONTRACT: u32 = 3;
96
97pub const SCHEMA_STANDARDS: u32 = 4;
102
103pub const SCHEMA_EVALUATORS: u32 = 5;
105
106pub const RESERVED_GATE_NAMES: &[&str] = &[
113 crate::contract_gates::VACUOUS_FILTER,
114 crate::contract_gates::WRONG_POLARITY,
115 crate::contract_gates::PASSES_ON_BASE,
116 crate::contract_gates::ENV_SENSITIVE,
117 "merge-gate-suite",
118];
119
120#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Pack {
124 pub name: String,
125 pub schema: u32,
126 pub dir: PathBuf,
129 pub gates: Vec<PackGateDecl>,
130 pub evaluators: Vec<evaluator::Declaration>,
131 pub prompts: Vec<PackPrompt>,
132 pub checklists: Vec<PackChecklist>,
133 pub artefact_stores: Vec<PackArtefactStore>,
134 pub standards: Option<standards::StandardsManifest>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct PackGateDecl {
146 pub name: String,
147 pub command: String,
148 pub when_paths: Vec<String>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct PackPrompt {
157 pub name: String,
158 pub role: Role,
159 pub text: String,
161 pub source: PromptSource,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum PromptSource {
168 Inline,
169 File(String),
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct PackChecklist {
176 pub name: String,
177 pub items: Vec<String>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct PackArtefactStore {
184 pub name: String,
185 pub kind: String,
186}
187
188impl Pack {
189 pub fn load(dir: &Path) -> Result<Option<Pack>, String> {
198 Self::load_with_trust(dir, standards::StandardsTrust::External)
199 }
200
201 pub fn load_with_trust(
206 dir: &Path,
207 trust: standards::StandardsTrust,
208 ) -> Result<Option<Pack>, String> {
209 let manifest_path = dir.join(PACK_MANIFEST);
210 let source = match std::fs::read_to_string(&manifest_path) {
211 Ok(source) => source,
212 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
213 Err(e) => return Err(format!("cannot read {}: {e}", manifest_path.display())),
214 };
215 Self::parse_with_trust(dir, &source, trust).map(Some)
216 }
217
218 pub fn parse(dir: &Path, source: &str) -> Result<Pack, String> {
223 Self::parse_with_trust(dir, source, standards::StandardsTrust::External)
224 }
225
226 pub fn parse_with_trust(
228 dir: &Path,
229 source: &str,
230 trust: standards::StandardsTrust,
231 ) -> Result<Pack, String> {
232 let doc = toml::parse(source).map_err(|e| format!("{PACK_MANIFEST}: {e}"))?;
233 Self::from_document(dir, &doc, trust)
234 }
235
236 fn from_document(
239 dir: &Path,
240 doc: &toml::Document,
241 trust: standards::StandardsTrust,
242 ) -> Result<Pack, String> {
243 validate_sections(doc)?;
244
245 let (name, schema) = manifest_header(doc)?;
246
247 let mut gates = Vec::new();
248 for (idx, item) in doc.array("gate").iter().enumerate() {
249 gates.push(load_gate(item, idx)?);
250 }
251 reject_duplicate_names("gate", gates.iter().map(|g| g.name.as_str()))?;
252
253 let evaluators = evaluator::declarations(doc, schema)?;
254 reject_duplicate_names(
255 "gate/evaluator",
256 gates
257 .iter()
258 .map(|g| g.name.as_str())
259 .chain(evaluators.iter().map(|e| e.name.as_str())),
260 )?;
261
262 let mut prompts = Vec::new();
263 for (idx, item) in doc.array("prompt").iter().enumerate() {
264 prompts.push(load_prompt(item, idx, dir)?);
265 }
266 reject_duplicate_names("prompt", prompts.iter().map(|p| p.name.as_str()))?;
267
268 let mut checklists = Vec::new();
269 for (idx, item) in doc.array("checklist").iter().enumerate() {
270 checklists.push(load_checklist(item, idx)?);
271 }
272 reject_duplicate_names("checklist", checklists.iter().map(|c| c.name.as_str()))?;
273
274 let mut artefact_stores = Vec::new();
275 for (idx, item) in doc.array("artefact_store").iter().enumerate() {
276 artefact_stores.push(load_artefact_store(item, idx)?);
277 }
278 reject_duplicate_names(
279 "artefact_store",
280 artefact_stores.iter().map(|s| s.name.as_str()),
281 )?;
282
283 let standards = match standards_root_of(doc, schema)? {
287 Some(root) => Some(standards::load_from_pack_dir(dir, &root, &gates, trust)?),
288 None => None,
289 };
290
291 Ok(Pack {
292 name,
293 schema,
294 dir: dir.to_path_buf(),
295 gates,
296 evaluators,
297 prompts,
298 checklists,
299 artefact_stores,
300 standards,
301 })
302 }
303
304 pub fn gates_for_paths(&self, changed_paths: &[String]) -> Vec<&PackGateDecl> {
308 self.gates
309 .iter()
310 .filter(|g| crate::merge_gate::when_paths_match(&g.when_paths, changed_paths))
311 .collect()
312 }
313
314 pub fn prompt_section(&self, role: Role) -> String {
319 let mut section = String::new();
320 for prompt in self.prompts.iter().filter(|p| p.role == role) {
321 section.push_str(&format!(
322 "\n\n---\nPack guidance (pack `{}`, prompt `{}`):\n{}\n",
323 self.name,
324 prompt.name,
325 prompt.text.trim_end()
326 ));
327 }
328 section
329 }
330
331 pub fn describe(&self) -> String {
334 let gate_names = self
335 .gates
336 .iter()
337 .map(|g| g.name.as_str())
338 .collect::<Vec<_>>()
339 .join(", ");
340 let prompt_names = self
341 .prompts
342 .iter()
343 .map(|p| format!("{}→{}", p.name, role_target_name(p.role)))
344 .collect::<Vec<_>>()
345 .join(", ");
346 let standards = match &self.standards {
349 Some(m) => format!(
350 ", standards: {} RFC(s)/{} rule(s) digest sha256:{}",
351 m.rfcs.len(),
352 m.rules.len(),
353 m.digest
354 ),
355 None => String::new(),
356 };
357 format!(
358 "pack `{}` (schema {}) at {}: {} gate(s) [{}], {} prompt(s) [{}], \
359 {} checklist(s), {} artefact store(s) (checklists/stores are \
360 declaration-only: validated at load, never executed){standards}",
361 self.name,
362 self.schema,
363 self.dir.display(),
364 self.gates.len(),
365 gate_names,
366 self.prompts.len(),
367 prompt_names,
368 self.checklists.len(),
369 self.artefact_stores.len(),
370 )
371 }
372}
373
374pub fn load_for_config(cfg: &MissionConfig, repo_root: &Path) -> Result<Option<Pack>, String> {
386 let Some(configured) = cfg.pack_dir.as_deref() else {
387 return Ok(None);
388 };
389 let raw = Path::new(configured);
390 let (dir, trust) = if raw.is_absolute() {
391 (raw.to_path_buf(), standards::StandardsTrust::External)
392 } else {
393 validate_pack_relative_path(configured, "mission config", "packDir")?;
394 let dir = repo_root.join(raw);
395 let trust = standards::trust_for_dir(repo_root, &dir);
396 (dir, trust)
397 };
398 if !dir.is_dir() {
399 return Err(format!(
400 "packDir `{configured}` resolves to {}, which is not a directory",
401 dir.display()
402 ));
403 }
404 let Some(pack) = Pack::load_with_trust(&dir, trust)? else {
405 return Err(format!(
406 "packDir `{configured}` resolves to {}, which has no {PACK_MANIFEST} — \
407 it is not a pack",
408 dir.display()
409 ));
410 };
411 if !pack.evaluators.is_empty() {
412 if standards::trust_for_dir(repo_root, &dir) != standards::StandardsTrust::RepoTracked
413 || raw.is_absolute()
414 {
415 return Err("external evaluators require a tracked repo-relative pack".into());
416 }
417 if !cfg!(any(target_os = "macos", target_os = "linux")) {
418 return Err("external mission evaluators require a supported contained host".into());
419 }
420 if pack.evaluators.iter().any(|e| {
421 e.stages
422 .contains(&crate::gate_evaluation::protocol::Stage::CommandPermission)
423 }) {
424 return Err("external command-permission evaluators are not connected; use the live permission consent path".into());
425 }
426 }
427 Ok(Some(pack))
428}
429
430pub fn render_lint(pack: &Pack) -> String {
433 let mut out = format!(
434 "pack `{}` (schema {}) at {} — valid\n",
435 pack.name,
436 pack.schema,
437 pack.dir.display()
438 );
439 out.push_str("gates (deterministic; final-gate, after the engine floor, advisory):\n");
440 if pack.gates.is_empty() {
441 out.push_str(" (none)\n");
442 }
443 for gate in &pack.gates {
444 let scoping = if gate.when_paths.is_empty() {
445 "unconditional".to_string()
446 } else {
447 format!("whenPaths: {}", gate.when_paths.join(", "))
448 };
449 out.push_str(&format!(
450 " - {}: `{}` ({scoping})\n",
451 gate.name, gate.command
452 ));
453 }
454 for evaluator in &pack.evaluators {
455 out.push_str(&format!(
456 "external evaluator {}: {:?}, {:?}; approval-pinned mission evaluation\n",
457 evaluator.name.as_str(),
458 evaluator.kind,
459 evaluator.stages
460 ));
461 }
462 out.push_str("prompts (appended to the target role's prompt):\n");
463 if pack.prompts.is_empty() {
464 out.push_str(" (none)\n");
465 }
466 for prompt in &pack.prompts {
467 let source = match &prompt.source {
468 PromptSource::Inline => "inline text".to_string(),
469 PromptSource::File(rel) => format!("file {rel}"),
470 };
471 out.push_str(&format!(
472 " - {} → {} ({source})\n",
473 prompt.name,
474 role_target_name(prompt.role)
475 ));
476 }
477 out.push_str("checklists (declaration-only: validated at load, never executed):\n");
478 if pack.checklists.is_empty() {
479 out.push_str(" (none)\n");
480 }
481 for checklist in &pack.checklists {
482 out.push_str(&format!(
483 " - {} ({} item(s))\n",
484 checklist.name,
485 checklist.items.len()
486 ));
487 }
488 out.push_str("artefact stores (declaration-only: validated at load, never invoked):\n");
489 if pack.artefact_stores.is_empty() {
490 out.push_str(" (none)\n");
491 }
492 for store in &pack.artefact_stores {
493 out.push_str(&format!(" - {} (kind `{}`)\n", store.name, store.kind));
494 }
495 if let Some(manifest) = &pack.standards {
496 out.push_str(&standards::render_registration(manifest));
497 }
498 out
499}
500
501pub fn role_target_name(role: Role) -> &'static str {
503 match role {
504 Role::Worker => "worker",
505 Role::ValidatorScrutiny => "validator-scrutiny",
506 Role::ValidatorFunctional => "validator-functional",
507 Role::Orchestrator => "orchestrator",
508 }
509}
510
511pub struct PackGate {
517 name: String,
518 outcome: GateOutcome,
519}
520
521impl PackGate {
522 pub fn from_run(name: &str, command: &str, ok: bool, output: String) -> Self {
527 let artefact = ArtefactRef::new(command.to_string());
528 let outcome = if ok {
529 GateOutcome::pass(artefact)
530 } else {
531 GateOutcome::fail(artefact.with_detail(output))
532 };
533 Self {
534 name: name.to_string(),
535 outcome,
536 }
537 }
538
539 pub fn with_rule_ids(mut self, rule_ids: Vec<String>) -> Self {
543 self.outcome = self.outcome.with_rule_ids(rule_ids);
544 self
545 }
546}
547
548impl Gate for PackGate {
549 fn name(&self) -> &str {
550 &self.name
551 }
552
553 fn kind(&self) -> GateKind {
554 GateKind::Deterministic
555 }
556
557 fn evaluate(&self) -> GateOutcome {
558 self.outcome.clone()
559 }
560}
561
562fn validate_sections(doc: &toml::Document) -> Result<(), String> {
568 for section in &doc.sections {
571 let name = match section {
572 toml::Section::Single(t) => t.name.as_str(),
573 toml::Section::Array { name, .. } => name.as_str(),
574 };
575 if ![
576 "pack",
577 "gate",
578 "prompt",
579 "checklist",
580 "artefact_store",
581 "standards",
582 "evaluator",
583 ]
584 .contains(&name)
585 {
586 return Err(format!(
587 "{PACK_MANIFEST}: unknown section `{name}` (declared sections: [pack], \
588 [[gate]], [[prompt]], [[checklist]], [[artefact_store]], [standards], [[evaluator]])"
589 ));
590 }
591 }
592 Ok(())
593}
594
595fn manifest_header(doc: &toml::Document) -> Result<(String, u32), String> {
600 let header = doc
601 .single("pack")
602 .ok_or_else(|| format!("{PACK_MANIFEST}: missing required table `[pack]`"))?;
603 check_unknown(header, "[pack]", &["name", "schema"])?;
604 let name = required_string(header, "[pack]", "name")?;
605 let schema = match header.get("schema") {
606 Some(toml::Value::Integer(n)) => {
607 let n = *n;
608 if n == i64::from(SCHEMA_BASE)
609 || n == i64::from(SCHEMA_CONTRACT)
610 || n == i64::from(SCHEMA_STANDARDS)
611 || n == i64::from(SCHEMA_EVALUATORS)
612 {
613 n as u32
614 } else {
615 return Err(format!(
616 "[pack] field `schema` is {n}: supported versions are {SCHEMA_BASE} \
617 (base manifest), {SCHEMA_CONTRACT} (contract), and {SCHEMA_STANDARDS} \
618 (standards), {SCHEMA_EVALUATORS} (external evaluators)"
619 ));
620 }
621 }
622 Some(v) => {
623 return Err(format!(
624 "[pack] field `schema` must be an integer, got {}",
625 v.type_name()
626 ))
627 }
628 None => return Err("[pack] is missing required field `schema`".to_string()),
629 };
630 Ok((name, schema))
631}
632
633fn standards_root_of(doc: &toml::Document, schema: u32) -> Result<Option<String>, String> {
639 let Some(table) = doc.single("standards") else {
640 return Ok(None);
641 };
642 if schema != SCHEMA_STANDARDS && schema != SCHEMA_EVALUATORS {
643 return Err(format!(
644 "[standards] requires [pack] field `schema` = {SCHEMA_STANDARDS} (this pack \
645 declares schema {schema}) — the standards root is additive at schema \
646 {SCHEMA_STANDARDS} and {SCHEMA_EVALUATORS}"
647 ));
648 }
649 check_unknown(table, "[standards]", &["root"])?;
650 let raw = required_string(table, "[standards]", "root")?;
651 validate_pack_relative_path(&raw, "[standards]", "root")?;
652 let normalized = crate::merge_gate::normalize_relative_path(&raw, false);
653 if normalized.is_empty() || normalized == "." {
654 return Err("[standards] field `root` must name a pack-relative directory".to_string());
655 }
656 Ok(Some(normalized))
657}
658
659fn entry_label(section: &str, index: usize, table: &toml::Table) -> String {
662 match table.get("name") {
663 Some(toml::Value::String(name)) => {
664 format!("[[{section}]] entry {} (name `{name}`)", index + 1)
665 }
666 _ => format!("[[{section}]] entry {}", index + 1),
667 }
668}
669
670fn check_unknown(table: &toml::Table, section: &str, known: &[&str]) -> Result<(), String> {
673 let unknown = table.unknown_keys(known);
674 if let Some(field) = unknown.first() {
675 return Err(format!(
676 "{section} has unknown field `{field}` (declared fields: {})",
677 known.join(", ")
678 ));
679 }
680 Ok(())
681}
682
683fn required_string(table: &toml::Table, section: &str, key: &str) -> Result<String, String> {
685 match table.get(key) {
686 Some(toml::Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
687 Some(toml::Value::String(_)) => Err(format!(
688 "{section} field `{key}` must be a non-empty string"
689 )),
690 Some(v) => Err(format!(
691 "{section} field `{key}` must be a string, got {}",
692 v.type_name()
693 )),
694 None => Err(format!("{section} is missing required field `{key}`")),
695 }
696}
697
698fn optional_string(
700 table: &toml::Table,
701 section: &str,
702 key: &str,
703) -> Result<Option<String>, String> {
704 match table.get(key) {
705 Some(toml::Value::String(s)) => Ok(Some(s.clone())),
706 Some(v) => Err(format!(
707 "{section} field `{key}` must be a string, got {}",
708 v.type_name()
709 )),
710 None => Ok(None),
711 }
712}
713
714fn optional_string_array(
716 table: &toml::Table,
717 section: &str,
718 key: &str,
719) -> Result<Vec<String>, String> {
720 match table.get(key) {
721 Some(toml::Value::Array(items)) => {
722 let mut out = Vec::with_capacity(items.len());
723 for (idx, item) in items.iter().enumerate() {
724 match item {
725 toml::Value::String(s) if !s.trim().is_empty() => out.push(s.clone()),
726 toml::Value::String(_) => {
727 return Err(format!(
728 "{section} field `{key}` element {} must be a non-empty string",
729 idx + 1
730 ))
731 }
732 v => {
733 return Err(format!(
734 "{section} field `{key}` element {} must be a string, got {}",
735 idx + 1,
736 v.type_name()
737 ))
738 }
739 }
740 }
741 Ok(out)
742 }
743 Some(v) => Err(format!(
744 "{section} field `{key}` must be an array of strings, got {}",
745 v.type_name()
746 )),
747 None => Ok(Vec::new()),
748 }
749}
750
751fn load_gate(table: &toml::Table, index: usize) -> Result<PackGateDecl, String> {
754 let section = entry_label("gate", index, table);
755 check_unknown(table, §ion, &["name", "kind", "command", "whenPaths"])?;
756 let name = required_string(table, §ion, "name")?;
757 if let Some(kind) = optional_string(table, §ion, "kind")? {
758 if kind != "deterministic" {
759 return Err(format!(
760 "{section} field `kind` is `{kind}`: packs may register only deterministic \
761 gates in this slice — model-judged gates are declared by the engine, \
762 never by a pack"
763 ));
764 }
765 }
766 if RESERVED_GATE_NAMES.contains(&name.as_str()) {
767 return Err(format!(
768 "{section} field `name` is `{name}`: reserved for an engine floor gate — \
769 a pack can add gates after the floor, never impersonate it"
770 ));
771 }
772 let command = required_string(table, §ion, "command")?;
773 if command.contains(['\n', '\r', '\0']) {
774 return Err(format!(
775 "{section} field `command` must be a single non-NUL line"
776 ));
777 }
778 let mut when_paths = Vec::new();
779 for raw in optional_string_array(table, §ion, "whenPaths")? {
780 validate_pack_relative_path(&raw, §ion, "whenPaths")?;
781 let normalized = crate::merge_gate::normalize_relative_path(&raw, false);
782 if normalized.is_empty() || normalized == "." {
783 return Err(format!(
784 "{section} field `whenPaths` entries must name a repo path — \
785 omit whenPaths to run unconditionally"
786 ));
787 }
788 when_paths.push(normalized);
789 }
790 Ok(PackGateDecl {
791 name,
792 command,
793 when_paths,
794 })
795}
796
797fn load_prompt(table: &toml::Table, index: usize, pack_dir: &Path) -> Result<PackPrompt, String> {
801 let section = entry_label("prompt", index, table);
802 check_unknown(table, §ion, &["name", "role", "text", "textFile"])?;
803 let name = required_string(table, §ion, "name")?;
804 let role_raw = required_string(table, §ion, "role")?;
805 let role = match role_raw.as_str() {
806 "worker" => Role::Worker,
807 "validator-scrutiny" => Role::ValidatorScrutiny,
808 "validator-functional" => Role::ValidatorFunctional,
809 other => {
810 return Err(format!(
811 "{section} field `role` is `{other}`: supported targets are `worker`, \
812 `validator-scrutiny`, `validator-functional` (the session roles whose \
813 prompts runner.rs builds)"
814 ))
815 }
816 };
817 let inline = optional_string(table, §ion, "text")?;
818 let file = optional_string(table, §ion, "textFile")?;
819 let (text, source) = match (inline, file) {
820 (Some(_), Some(_)) => {
821 return Err(format!(
822 "{section} declares both `text` and `textFile` — exactly one is required"
823 ))
824 }
825 (None, None) => {
826 return Err(format!(
827 "{section} is missing required field `text` (or `textFile`)"
828 ))
829 }
830 (Some(text), None) => (text, PromptSource::Inline),
831 (None, Some(rel)) => {
832 validate_pack_relative_path(&rel, §ion, "textFile")?;
833 let normalized = crate::merge_gate::normalize_relative_path(&rel, false);
834 let text = read_pack_text_file_nofollow(pack_dir, &normalized, §ion)?;
835 (text, PromptSource::File(normalized))
836 }
837 };
838 if text.trim().is_empty() {
839 return Err(format!(
840 "{section} field `text` resolves to empty prompt text"
841 ));
842 }
843 Ok(PackPrompt {
844 name,
845 role,
846 text,
847 source,
848 })
849}
850
851fn load_checklist(table: &toml::Table, index: usize) -> Result<PackChecklist, String> {
853 let section = entry_label("checklist", index, table);
854 check_unknown(table, §ion, &["name", "items"])?;
855 let name = required_string(table, §ion, "name")?;
856 if table.get("items").is_none() {
857 return Err(format!("{section} is missing required field `items`"));
858 }
859 let items = optional_string_array(table, §ion, "items")?;
860 if items.is_empty() {
861 return Err(format!(
862 "{section} field `items` must list at least one item"
863 ));
864 }
865 Ok(PackChecklist { name, items })
866}
867
868fn load_artefact_store(table: &toml::Table, index: usize) -> Result<PackArtefactStore, String> {
870 let section = entry_label("artefact_store", index, table);
871 check_unknown(table, §ion, &["name", "kind"])?;
872 let name = required_string(table, §ion, "name")?;
873 let kind = required_string(table, §ion, "kind")?;
874 Ok(PackArtefactStore { name, kind })
875}
876
877fn reject_duplicate_names<'a>(
881 section: &str,
882 names: impl Iterator<Item = &'a str>,
883) -> Result<(), String> {
884 let mut seen = HashSet::new();
885 for name in names {
886 if !seen.insert(name) {
887 return Err(format!("duplicate [[{section}]] name `{name}`"));
888 }
889 }
890 Ok(())
891}
892
893fn validate_pack_relative_path(raw: &str, section: &str, field: &str) -> Result<(), String> {
897 let path = Path::new(raw);
898 if raw.trim().is_empty()
899 || path.is_absolute()
900 || path
901 .components()
902 .any(|part| !matches!(part, Component::CurDir | Component::Normal(_)))
903 {
904 return Err(format!(
905 "{section} field `{field}` must be a pack-relative path without parent \
906 components: {raw:?}"
907 ));
908 }
909 Ok(())
910}
911
912fn read_pack_text_file_nofollow(
927 pack_dir: &Path,
928 rel: &str,
929 section: &str,
930) -> Result<String, String> {
931 use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
932 use std::io::Read as _;
933
934 let display = pack_dir.join(rel);
935 let field_error = |message: String| format!("{section} field `textFile` = {rel:?} {message}");
936 let no_follow_refusal = |what: &str| {
937 field_error(format!(
938 "resolves through {what} ({}) — pack prompt files load no-follow so a pack \
939 cannot read outside its own directory",
940 display.display()
941 ))
942 };
943 let mut dir = cap_std::fs::Dir::open_ambient_dir(pack_dir, cap_std::ambient_authority())
944 .map_err(|e| field_error(format!("cannot open pack dir {}: {e}", pack_dir.display())))?;
945 let mut names = rel.split('/').peekable();
946 while let Some(name) = names.next() {
947 if names.peek().is_some() {
948 dir = dir
949 .open_dir_nofollow(name)
950 .map_err(|_| no_follow_refusal("a symlinked or non-directory component"))?;
951 } else {
952 let mut options = cap_std::fs::OpenOptions::new();
953 options.read(true).follow(FollowSymlinks::No);
954 let mut file = dir.open_with(name, &options).map_err(|e| {
955 if e.kind() == std::io::ErrorKind::NotFound {
956 field_error(format!("cannot be read at {}: {e}", display.display()))
957 } else {
958 no_follow_refusal("a symlink or other non-regular file")
959 }
960 })?;
961 let mut text = String::new();
962 file.read_to_string(&mut text).map_err(|e| {
963 field_error(format!("cannot be read at {}: {e}", display.display()))
964 })?;
965 return Ok(text);
966 }
967 }
968 Err(field_error("resolves to no file".to_string()))
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976 use crate::gate::GatePipeline;
977
978 fn pack_dir_with(manifest: &str, files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
981 let tmp = tempfile::tempdir().expect("tempdir");
982 let dir = tmp.path().join("pack");
983 std::fs::create_dir_all(&dir).unwrap();
984 std::fs::write(dir.join(PACK_MANIFEST), manifest).unwrap();
985 for (rel, body) in files {
986 let path = dir.join(rel);
987 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
988 std::fs::write(path, body).unwrap();
989 }
990 (tmp, dir)
991 }
992
993 const FULL_MANIFEST: &str = r#"
994[pack]
995name = "zz-synthetic-pack"
996schema = 3
997
998[[gate]]
999name = "zz-gate-one"
1000command = "cd ."
1001
1002[[gate]]
1003name = "zz-gate-two"
1004command = "cd ."
1005whenPaths = ["src/"]
1006
1007[[prompt]]
1008name = "zz-prompt-worker"
1009role = "worker"
1010text = "zz inline worker guidance"
1011
1012[[prompt]]
1013name = "zz-prompt-scrutiny"
1014role = "validator-scrutiny"
1015textFile = "prompts/scrutiny.md"
1016
1017[[checklist]]
1018name = "zz-checklist"
1019items = ["first", "second"]
1020
1021[[artefact_store]]
1022name = "zz-store"
1023kind = "local-dir"
1024"#;
1025
1026 #[test]
1027 fn pack_contract_full_pack_loads_all_sections() {
1028 let (_tmp, dir) =
1029 pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
1030 let pack = Pack::load(&dir).expect("load").expect("a pack");
1031 assert_eq!(pack.name, "zz-synthetic-pack");
1032 assert_eq!(pack.schema, SCHEMA_CONTRACT);
1033 assert_eq!(pack.gates.len(), 2);
1034 assert_eq!(pack.gates[1].when_paths, vec!["src".to_string()]);
1035 assert_eq!(pack.prompts.len(), 2);
1036 assert_eq!(pack.prompts[1].text, "zz file text\n");
1037 assert_eq!(
1038 pack.prompts[1].source,
1039 PromptSource::File("prompts/scrutiny.md".to_string())
1040 );
1041 assert_eq!(pack.checklists[0].items.len(), 2);
1042 assert_eq!(pack.artefact_stores[0].kind, "local-dir");
1043 }
1044
1045 #[test]
1048 fn pack_contract_schema_two_base_manifest_registers_nothing() {
1049 let (_tmp, dir) = pack_dir_with("[pack]\nname = \"kranz\"\nschema = 2\n", &[]);
1050 let pack = Pack::load(&dir).expect("load").expect("a pack");
1051 assert_eq!(pack.schema, SCHEMA_BASE);
1052 assert!(pack.gates.is_empty());
1053 assert!(pack.prompts.is_empty());
1054 assert!(pack.checklists.is_empty());
1055 assert!(pack.artefact_stores.is_empty());
1056 }
1057
1058 #[test]
1059 fn pack_contract_directory_without_manifest_is_not_a_pack() {
1060 let tmp = tempfile::tempdir().unwrap();
1061 assert_eq!(Pack::load(tmp.path()).expect("load"), None);
1062 }
1063
1064 #[test]
1067 fn pack_contract_unknown_field_fails_closed() {
1068 let (_tmp, dir) = pack_dir_with(
1069 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nbogus = 1\n",
1070 &[],
1071 );
1072 let err = Pack::load(&dir).expect_err("must fail");
1073 assert!(err.contains("unknown field `bogus`"), "{err}");
1074 assert!(err.contains("[[gate]]"), "{err}");
1075 }
1076
1077 #[test]
1078 fn pack_contract_unknown_section_fails_closed() {
1079 let (_tmp, dir) = pack_dir_with(
1080 "[pack]\nname = \"x\"\nschema = 3\n\n[[gates]]\nname = \"g\"\ncommand = \"true\"\n",
1081 &[],
1082 );
1083 let err = Pack::load(&dir).expect_err("must fail");
1084 assert!(err.contains("unknown section `gates`"), "{err}");
1085 }
1086
1087 #[test]
1088 fn pack_contract_missing_required_key_fails_closed() {
1089 let (_tmp, dir) = pack_dir_with(
1091 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\n",
1092 &[],
1093 );
1094 let err = Pack::load(&dir).expect_err("must fail");
1095 assert!(err.contains("missing required field `command`"), "{err}");
1096
1097 let (_tmp2, dir2) = pack_dir_with("[pack]\nname = \"x\"\n", &[]);
1099 let err = Pack::load(&dir2).expect_err("must fail");
1100 assert!(err.contains("missing required field `schema`"), "{err}");
1101 }
1102
1103 #[test]
1104 fn pack_contract_wrong_type_fails_closed() {
1105 let (_tmp, dir) = pack_dir_with("[pack]\nname = \"x\"\nschema = \"3\"\n", &[]);
1106 let err = Pack::load(&dir).expect_err("must fail");
1107 assert!(err.contains("field `schema` must be an integer"), "{err}");
1108
1109 let (_tmp2, dir2) = pack_dir_with(
1110 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nwhenPaths = \"src\"\n",
1111 &[],
1112 );
1113 let err = Pack::load(&dir2).expect_err("must fail");
1114 assert!(
1115 err.contains("field `whenPaths` must be an array of strings"),
1116 "{err}"
1117 );
1118 }
1119
1120 #[test]
1121 fn pack_contract_duplicate_name_fails_closed() {
1122 let (_tmp, dir) = pack_dir_with(
1123 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\n\n[[gate]]\nname = \"g\"\ncommand = \"false\"\n",
1124 &[],
1125 );
1126 let err = Pack::load(&dir).expect_err("must fail");
1127 assert!(err.contains("duplicate [[gate]] name `g`"), "{err}");
1128 }
1129
1130 #[test]
1131 fn pack_contract_model_judged_gate_kind_refused() {
1132 let (_tmp, dir) = pack_dir_with(
1133 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nkind = \"model-judged\"\n",
1134 &[],
1135 );
1136 let err = Pack::load(&dir).expect_err("must fail");
1137 assert!(err.contains("field `kind` is `model-judged`"), "{err}");
1138 assert!(err.contains("deterministic"), "{err}");
1139 }
1140
1141 #[test]
1142 fn pack_contract_engine_floor_gate_names_are_reserved() {
1143 for reserved in RESERVED_GATE_NAMES {
1144 let manifest = format!(
1145 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"{reserved}\"\ncommand = \"true\"\n"
1146 );
1147 let (_tmp, dir) = pack_dir_with(&manifest, &[]);
1148 let err = Pack::load(&dir).expect_err("must fail");
1149 assert!(err.contains("reserved for an engine floor gate"), "{err}");
1150 }
1151 }
1152
1153 #[test]
1154 fn pack_contract_empty_gate_command_fails_closed() {
1155 let (_tmp, dir) = pack_dir_with(
1156 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \" \"\n",
1157 &[],
1158 );
1159 let err = Pack::load(&dir).expect_err("must fail");
1160 assert!(
1161 err.contains("field `command` must be a non-empty string"),
1162 "{err}"
1163 );
1164 }
1165
1166 #[test]
1167 fn pack_contract_unsupported_schema_fails_closed() {
1168 let (_tmp, dir) = pack_dir_with("[pack]\nname = \"x\"\nschema = 6\n", &[]);
1169 let err = Pack::load(&dir).expect_err("must fail");
1170 assert!(err.contains("field `schema` is 6"), "{err}");
1171 }
1172
1173 #[test]
1174 fn pack_contract_prompt_text_and_textfile_are_exclusive() {
1175 let (_tmp, dir) = pack_dir_with(
1176 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntext = \"t\"\ntextFile = \"p.md\"\n",
1177 &[],
1178 );
1179 let err = Pack::load(&dir).expect_err("must fail");
1180 assert!(err.contains("both `text` and `textFile`"), "{err}");
1181
1182 let (_tmp2, dir2) = pack_dir_with(
1183 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\n",
1184 &[],
1185 );
1186 let err = Pack::load(&dir2).expect_err("must fail");
1187 assert!(err.contains("missing required field `text`"), "{err}");
1188 }
1189
1190 #[test]
1191 fn pack_contract_prompt_role_must_target_a_session_role() {
1192 let (_tmp, dir) = pack_dir_with(
1193 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"orchestrator\"\ntext = \"t\"\n",
1194 &[],
1195 );
1196 let err = Pack::load(&dir).expect_err("must fail");
1197 assert!(err.contains("field `role` is `orchestrator`"), "{err}");
1198 }
1199
1200 #[test]
1201 fn pack_contract_prompt_textfile_must_stay_inside_the_pack() {
1202 let (_tmp, dir) = pack_dir_with(
1203 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"../escape.md\"\n",
1204 &[],
1205 );
1206 let err = Pack::load(&dir).expect_err("must fail");
1207 assert!(
1208 err.contains("field `textFile` must be a pack-relative path"),
1209 "{err}"
1210 );
1211
1212 let (_tmp2, dir2) = pack_dir_with(
1213 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"missing.md\"\n",
1214 &[],
1215 );
1216 let err = Pack::load(&dir2).expect_err("must fail");
1217 assert!(
1218 err.contains("field `textFile` = \"missing.md\" cannot be read"),
1219 "{err}"
1220 );
1221 }
1222
1223 #[test]
1224 fn pack_contract_checklist_requires_items() {
1225 let (_tmp, dir) = pack_dir_with(
1226 "[pack]\nname = \"x\"\nschema = 3\n\n[[checklist]]\nname = \"c\"\n",
1227 &[],
1228 );
1229 let err = Pack::load(&dir).expect_err("must fail");
1230 assert!(err.contains("missing required field `items`"), "{err}");
1231 }
1232
1233 #[test]
1236 fn pack_contract_prompt_section_targets_only_the_named_role() {
1237 let (_tmp, dir) =
1238 pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
1239 let pack = Pack::load(&dir).unwrap().unwrap();
1240 let worker = pack.prompt_section(Role::Worker);
1241 assert!(worker.contains("zz-prompt-worker"), "{worker}");
1242 assert!(worker.contains("zz inline worker guidance"), "{worker}");
1243 assert!(!worker.contains("zz-prompt-scrutiny"), "{worker}");
1244 let scrutiny = pack.prompt_section(Role::ValidatorScrutiny);
1245 assert!(scrutiny.contains("zz file text"), "{scrutiny}");
1246 assert!(!scrutiny.contains("zz-prompt-worker"), "{scrutiny}");
1247 assert_eq!(pack.prompt_section(Role::ValidatorFunctional), "");
1248 }
1249
1250 #[test]
1251 fn pack_contract_when_paths_scope_gates_like_the_merge_suite() {
1252 let (_tmp, dir) =
1253 pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
1254 let pack = Pack::load(&dir).unwrap().unwrap();
1255 let changed = vec!["crates/engine/src/lib.rs".to_string()];
1256 let applicable: Vec<&str> = pack
1257 .gates_for_paths(&changed)
1258 .iter()
1259 .map(|g| g.name.as_str())
1260 .collect();
1261 assert_eq!(applicable, vec!["zz-gate-one"], "scoped gate skipped");
1262 let changed = vec!["src/widget.ts".to_string()];
1263 let applicable: Vec<&str> = pack
1264 .gates_for_paths(&changed)
1265 .iter()
1266 .map(|g| g.name.as_str())
1267 .collect();
1268 assert_eq!(applicable, vec!["zz-gate-one", "zz-gate-two"]);
1269 }
1270
1271 #[test]
1275 fn pack_contract_gates_never_precede_or_displace_engine_floor_gates() {
1276 use crate::types::{Assertion, AssertionCheck};
1277 let contract = vec![Assertion {
1278 id: "a1".to_string(),
1279 statement: "s".to_string(),
1280 check: AssertionCheck::Command,
1281 command: Some("cargo test --workspace zz_pack_contract_floor 2>&1 | grep -qE 'test result: ok\\. [1-9]'".to_string()),
1282 negative_control: None,
1283 pty_script: None,
1284 }];
1285 let tree = tempfile::tempdir().unwrap();
1286 let mut pipeline = GatePipeline::new();
1287 crate::contract_gates::register_contract_gates(&mut pipeline, &contract, None, tree.path());
1289 let floor_len = pipeline.len();
1290 assert!(floor_len > 0, "floor gates registered");
1291 pipeline.register(Box::new(PackGate::from_run(
1292 "zz-pack-gate",
1293 "cd .",
1294 true,
1295 String::new(),
1296 )));
1297 let reports = pipeline.evaluate();
1298 let names: Vec<&str> = reports.iter().map(|r| r.name.as_str()).collect();
1299 assert_eq!(
1300 names.last(),
1301 Some(&"zz-pack-gate"),
1302 "the pack gate evaluates LAST: {names:?}"
1303 );
1304 let floor_names = &names[..floor_len];
1305 assert!(floor_names.contains(&crate::contract_gates::VACUOUS_FILTER));
1306 assert!(floor_names.contains(&crate::contract_gates::ENV_SENSITIVE));
1307 assert!(
1308 !floor_names.contains(&"zz-pack-gate"),
1309 "no pack gate inside the floor section"
1310 );
1311 assert_eq!(
1312 reports.len(),
1313 floor_len + 1,
1314 "the floor is intact — added to, never displaced"
1315 );
1316 }
1317
1318 #[test]
1319 fn pack_contract_pack_gate_carries_the_run_outcome() {
1320 use crate::gate::Gate;
1321 let pass = PackGate::from_run("g", "cd .", true, String::new());
1322 assert_eq!(pass.kind(), GateKind::Deterministic);
1323 assert!(pass.evaluate().passed());
1324 assert_eq!(pass.evaluate().artefact.reference, "cd .");
1325 let fail = PackGate::from_run("g", "cd .", false, "boom".to_string());
1326 assert!(!fail.evaluate().passed());
1327 assert_eq!(fail.evaluate().artefact.detail.as_deref(), Some("boom"));
1328 assert_eq!(fail.evaluate().score, None, "boolean-only gate");
1329 }
1330
1331 #[test]
1332 fn pack_contract_load_for_config_resolves_repo_relative_and_refuses_non_packs() {
1333 let repo = tempfile::tempdir().unwrap();
1334 let cfg = MissionConfig::default();
1336 assert_eq!(load_for_config(&cfg, repo.path()).unwrap(), None);
1337
1338 let (_tmp, pack_src) = pack_dir_with("[pack]\nname = \"x\"\nschema = 3\n", &[]);
1340 let rel = repo.path().join("my-pack");
1341 std::fs::create_dir_all(&rel).unwrap();
1342 std::fs::copy(pack_src.join(PACK_MANIFEST), rel.join(PACK_MANIFEST)).unwrap();
1343 let cfg = MissionConfig {
1344 pack_dir: Some("my-pack".to_string()),
1345 ..MissionConfig::default()
1346 };
1347 let pack = load_for_config(&cfg, repo.path()).unwrap().expect("a pack");
1348 assert_eq!(pack.name, "x");
1349
1350 std::fs::create_dir_all(repo.path().join("not-a-pack")).unwrap();
1352 let cfg = MissionConfig {
1353 pack_dir: Some("not-a-pack".to_string()),
1354 ..MissionConfig::default()
1355 };
1356 let err = load_for_config(&cfg, repo.path()).expect_err("must fail");
1357 assert!(err.contains("it is not a pack"), "{err}");
1358
1359 let cfg = MissionConfig {
1360 pack_dir: Some("missing-dir".to_string()),
1361 ..MissionConfig::default()
1362 };
1363 let err = load_for_config(&cfg, repo.path()).expect_err("must fail");
1364 assert!(err.contains("is not a directory"), "{err}");
1365
1366 let cfg = MissionConfig {
1370 pack_dir: Some("../pack".to_string()),
1371 ..MissionConfig::default()
1372 };
1373 let err = load_for_config(&cfg, repo.path()).expect_err("traversal must fail");
1374 assert!(err.contains("without parent components"), "{err}");
1375 }
1376
1377 #[cfg(unix)]
1387 #[test]
1388 fn pack_textfile_nofollow_refuses_a_symlinked_leaf() {
1389 use std::os::unix::fs::symlink;
1390 let tmp = tempfile::tempdir().unwrap();
1391 let outside_file = tmp.path().join("engine-readable-secret.md");
1392 std::fs::write(&outside_file, "sk-live-secret-value").unwrap();
1393 let pack = tmp.path().join("pack");
1394 std::fs::create_dir_all(pack.join("prompts")).unwrap();
1395 std::fs::write(
1396 pack.join(PACK_MANIFEST),
1397 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1398 )
1399 .unwrap();
1400 symlink(&outside_file, pack.join("prompts").join("scrutiny.md")).unwrap();
1401
1402 let err = Pack::load(&pack).expect_err("a symlinked textFile must be refused");
1403 assert!(err.contains("field `textFile`"), "names the field: {err}");
1404 assert!(err.contains("no-follow"), "says why: {err}");
1405 }
1406
1407 #[cfg(unix)]
1410 #[test]
1411 fn pack_textfile_nofollow_refuses_a_symlinked_parent_dir() {
1412 use std::os::unix::fs::symlink;
1413 let tmp = tempfile::tempdir().unwrap();
1414 let outside = tmp.path().join("outside");
1415 std::fs::create_dir_all(&outside).unwrap();
1416 std::fs::write(outside.join("scrutiny.md"), "exfiltrated prompt text").unwrap();
1417 let pack = tmp.path().join("pack");
1418 std::fs::create_dir_all(&pack).unwrap();
1419 std::fs::write(
1420 pack.join(PACK_MANIFEST),
1421 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1422 )
1423 .unwrap();
1424 symlink(&outside, pack.join("prompts")).unwrap();
1425
1426 let err = Pack::load(&pack).expect_err("a symlinked parent dir must be refused");
1427 assert!(err.contains("field `textFile`"), "names the field: {err}");
1428 assert!(err.contains("no-follow"), "says why: {err}");
1429 }
1430
1431 #[test]
1435 fn pack_textfile_nofollow_plain_in_pack_file_loads() {
1436 let (_tmp, dir) = pack_dir_with(
1437 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1438 &[("prompts/scrutiny.md", "zz plain in-pack text\n")],
1439 );
1440 let pack = Pack::load(&dir).expect("load").expect("a pack");
1441 assert_eq!(pack.prompts[0].text, "zz plain in-pack text\n");
1442 assert_eq!(
1443 pack.prompts[0].source,
1444 PromptSource::File("prompts/scrutiny.md".to_string())
1445 );
1446 }
1447}