use alloc::{format, string::String, vec::Vec};
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[error("invalid candidate custody: {0}")]
pub struct CandidateError(pub String);
type Result<T = ()> = core::result::Result<T, CandidateError>;
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct FileReference {
pub path: String,
pub sha256: String,
pub bytes: u64,
}
impl FileReference {
pub fn validate(&self) -> Result {
if self.path.is_empty()
|| self.path.contains('\0')
|| !exact_hex(&self.sha256, 64)
|| self.bytes == 0
{
return Err(CandidateError(
"artifact lacks a path, byte count, or exact SHA-256".into(),
));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProductSource {
pub root: String,
pub commit: String,
pub tree: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProductFile {
pub path: String,
pub mode: String,
pub sha256: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct CompilerDigests {
pub claude: String,
pub codex: String,
pub pi: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProductArtifacts {
pub native_cli: FileReference,
pub component_wasm: FileReference,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProductManifest {
pub schema: String,
pub run: String,
pub source: ProductSource,
pub files: Vec<ProductFile>,
pub compiler: CompilerDigests,
pub artifacts: ProductArtifacts,
}
impl ProductManifest {
pub fn validate(&self) -> Result {
if self.schema != "shepherd.product-manifest/1"
|| crate::dispatch::RunId::new(&self.run).is_err()
|| self.source.root.is_empty()
|| !exact_hex(&self.source.commit, 40)
|| !exact_hex(&self.source.tree, 40)
|| self.files.is_empty()
|| self.files.len() > 65_536
|| [
&self.compiler.claude,
&self.compiler.codex,
&self.compiler.pi,
]
.iter()
.any(|digest| !exact_hex(digest, 64))
{
return Err(CandidateError(
"manifest schema, run, source, compiler or inventory is invalid".into(),
));
}
let mut previous = None;
for file in &self.files {
if !canonical_relative(&file.path)
|| excluded_product_path(&file.path)
|| previous.is_some_and(|path: &str| path >= file.path.as_str())
|| !exact_hex(&file.sha256, 64)
|| !matches!(file.mode.as_str(), "100644" | "100755" | "120000")
|| (file.mode == "120000" && product_symlink_target(&file.path).is_none())
{
return Err(CandidateError(format!(
"product inventory is not canonical at {}",
file.path
)));
}
previous = Some(file.path.as_str());
}
self.artifacts.native_cli.validate()?;
self.artifacts.component_wasm.validate()?;
Ok(())
}
}
#[derive(
Clone,
Copy,
Debug,
Eq,
PartialEq,
serde::Deserialize,
serde::Serialize,
strum::AsRefStr,
strum::Display,
strum::EnumCount,
strum::EnumIs,
strum::EnumString,
strum::IntoStaticStr,
strum::VariantNames,
)]
#[serde(rename_all = "kebab-case")]
#[strum(ascii_case_insensitive, serialize_all = "snake_case")]
pub enum CandidateState {
Frozen,
SourceVerified,
Packing,
Packages,
Attested,
Revoked,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateRecord {
pub schema: String,
pub candidate_id: String,
pub run: String,
pub run_incarnation: String,
pub state: CandidateState,
pub source_commit: String,
pub source_tree: String,
pub product_manifest_sha256: String,
pub product_manifest: ProductManifest,
pub packages_manifest: Option<FileReference>,
pub lifecycle_manifest: Option<FileReference>,
pub revocation_reason: Option<String>,
}
impl CandidateRecord {
pub fn freeze(
candidate_id: String,
run_incarnation: String,
manifest: ProductManifest,
manifest_sha256: String,
) -> Result<Self> {
let record = Self {
schema: "shepherd.candidate-record/1".into(),
candidate_id,
run: manifest.run.clone(),
run_incarnation,
state: CandidateState::Frozen,
source_commit: manifest.source.commit.clone(),
source_tree: manifest.source.tree.clone(),
product_manifest_sha256: manifest_sha256,
product_manifest: manifest,
packages_manifest: None,
lifecycle_manifest: None,
revocation_reason: None,
};
record.validate()?;
Ok(record)
}
pub fn validate(&self) -> Result {
self.product_manifest.validate()?;
if self.schema != "shepherd.candidate-record/1"
|| !exact_hex(&self.candidate_id, 64)
|| self.run_incarnation.is_empty()
|| self.run != self.product_manifest.run
|| self.source_commit != self.product_manifest.source.commit
|| self.source_tree != self.product_manifest.source.tree
|| !exact_hex(&self.product_manifest_sha256, 64)
|| (self.state == CandidateState::Revoked) != self.revocation_reason.is_some()
|| self
.revocation_reason
.as_ref()
.is_some_and(|reason| reason.trim().is_empty())
|| (self.lifecycle_manifest.is_some() && self.packages_manifest.is_none())
{
return Err(CandidateError(
"record identity or revocation state is invalid".into(),
));
}
let stage_shape = match self.state {
CandidateState::Frozen | CandidateState::SourceVerified | CandidateState::Packing => {
self.packages_manifest.is_none() && self.lifecycle_manifest.is_none()
}
CandidateState::Packages => {
self.packages_manifest.is_some() && self.lifecycle_manifest.is_none()
}
CandidateState::Attested => {
self.packages_manifest.is_some() && self.lifecycle_manifest.is_some()
}
CandidateState::Revoked => true,
};
if !stage_shape {
return Err(CandidateError(
"record stage and attestations disagree".into(),
));
}
for artifact in [&self.packages_manifest, &self.lifecycle_manifest]
.into_iter()
.flatten()
{
artifact.validate()?;
}
Ok(())
}
pub fn verify_source(&mut self) -> Result {
self.validate()?;
if self.state == CandidateState::Revoked {
return Err(CandidateError("candidate is revoked".into()));
}
if self.state == CandidateState::Frozen {
self.state = CandidateState::SourceVerified;
}
Ok(())
}
pub fn reserve_pack(&mut self) -> Result {
self.require(CandidateState::SourceVerified)?;
self.state = CandidateState::Packing;
Ok(())
}
pub fn attest_packages(&mut self, manifest: FileReference) -> Result {
self.require(CandidateState::Packing)?;
manifest.validate()?;
self.packages_manifest = Some(manifest);
self.state = CandidateState::Packages;
Ok(())
}
pub fn attest_lifecycle(&mut self, manifest: FileReference) -> Result {
self.require(CandidateState::Packages)?;
manifest.validate()?;
self.lifecycle_manifest = Some(manifest);
self.state = CandidateState::Attested;
Ok(())
}
pub fn revoke(&mut self, reason: String) -> Result {
self.validate()?;
if self.state == CandidateState::Revoked || reason.trim().is_empty() || reason.len() > 4096
{
return Err(CandidateError(
"revocation requires a live candidate and bounded non-empty reason".into(),
));
}
self.state = CandidateState::Revoked;
self.revocation_reason = Some(reason);
Ok(())
}
fn require(&self, expected: CandidateState) -> Result {
self.validate()?;
if self.state != expected {
return Err(CandidateError(format!(
"operation requires {expected:?}, found {:?}",
self.state
)));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateCustody {
pub schema: String,
pub current: CandidateRecord,
pub history: Vec<CandidateRecord>,
}
impl CandidateCustody {
pub fn new(current: CandidateRecord) -> Result<Self> {
let custody = Self {
schema: "shepherd.candidate-custody/1".into(),
current,
history: Vec::new(),
};
custody.validate()?;
Ok(custody)
}
pub fn validate(&self) -> Result {
self.current.validate()?;
if self.schema != "shepherd.candidate-custody/1" || self.history.len() > 64 {
return Err(CandidateError(
"custody schema or history size is invalid".into(),
));
}
let mut identities = alloc::collections::BTreeSet::new();
identities.insert(&self.current.candidate_id);
for prior in &self.history {
prior.validate()?;
if prior.state != CandidateState::Revoked
|| prior.run != self.current.run
|| prior.run_incarnation != self.current.run_incarnation
|| !identities.insert(&prior.candidate_id)
{
return Err(CandidateError(
"candidate history is not unique revoked lineage".into(),
));
}
}
Ok(())
}
pub fn refreeze(&mut self, next: CandidateRecord) -> Result {
self.validate()?;
next.validate()?;
if self.current.state != CandidateState::Revoked
|| self.history.len() >= 64
|| next.state != CandidateState::Frozen
|| next.run != self.current.run
|| next.run_incarnation != self.current.run_incarnation
|| next.candidate_id == self.current.candidate_id
|| self
.history
.iter()
.any(|prior| prior.candidate_id == next.candidate_id)
{
return Err(CandidateError(
"refreeze requires a new identity after explicit revocation".into(),
));
}
self.history
.push(core::mem::replace(&mut self.current, next));
Ok(())
}
}
#[must_use]
pub fn exact_hex(value: &str, length: usize) -> bool {
value.len() == length
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
#[must_use]
pub fn canonical_relative(path: &str) -> bool {
!path.is_empty()
&& !path.contains(['\\', '\0', ':'])
&& path.split('/').all(|part| !matches!(part, "" | "." | ".."))
}
#[must_use]
pub fn product_symlink_target(path: &str) -> Option<&'static str> {
match path {
"CLAUDE.md" => Some("AGENTS.md"),
"plugins/shepherd/agents" => Some("../../agents"),
"plugins/shepherd/hooks/hooks.json" => Some("../../../hooks/hooks.json"),
"plugins/shepherd/hooks/scripts" => Some("../../../hooks/scripts"),
"plugins/shepherd/skills" => Some("../../skills"),
_ => None,
}
}
#[must_use]
pub fn evidence_only_path(run: &str, path: &str) -> bool {
if !canonical_relative(path) {
return false;
}
let parts: Vec<_> = path.split('/').collect();
if parts.len() < 4 || parts[..2] != [".shepherd", "runs"] || parts[2] != run {
return false;
}
matches!(
&parts[3..],
["close.md" | "handoff.md" | "attestation.json"]
) || (parts[3] == "evidence" && parts.len() >= 5)
|| matches!(&parts[3..], ["lanes", _, "handoff.md"])
|| (parts.len() >= 7 && parts[3] == "lanes" && parts[5] == "evidence")
}
#[must_use]
pub fn excluded_product_path(path: &str) -> bool {
let parts: Vec<_> = path.split('/').collect();
if matches!(
parts.first(),
Some(&".git" | &"target" | &"targets" | &"node_modules" | &".superpowers")
) || parts.contains(&"node_modules")
|| path.starts_with(".shepherd/tmp/")
|| matches!(
path,
".shepherd/project.json"
| ".shepherd/shepherd.lock"
| ".shepherd/shepherd.db"
| ".shepherd/shepherd.db-wal"
| ".shepherd/shepherd.db-shm"
)
{
return true;
}
if parts.first() == Some(&".artifacts") {
return true;
}
if parts.len() >= 4 && parts[..2] == [".shepherd", "runs"] {
return evidence_only_path(parts[2], path)
|| matches!(
&parts[3..],
["run.json" | "run.lock" | "orientation-pre.json" | "orientation-post.json"]
)
|| parts[3] == "dispatch";
}
false
}