diff --git a/crates/mvm-cli/src/commands/vm/exec.rs b/crates/mvm-cli/src/commands/vm/exec.rs index 1c21d7126a..5b14f1d916 100644 --- a/crates/mvm-cli/src/commands/vm/exec.rs +++ b/crates/mvm-cli/src/commands/vm/exec.rs @@ -395,6 +395,10 @@ pub(in crate::commands) struct RunArgs { /// surfaces cannot reopen something it denied. #[arg(skip)] pub applied_policy: Option, + /// Internal: the project manifest selected when policy was resolved. + /// Later route and approval resolution must use the same parsed bytes. + #[arg(skip)] + pub(in crate::commands) project_manifest: Option, /// Internal: effective AI limits retained from the ordinary policy fold. #[arg(skip)] pub applied_ai_policy: Option, diff --git a/crates/mvm-cli/src/commands/vm/exec/run_args_default.rs b/crates/mvm-cli/src/commands/vm/exec/run_args_default.rs index 6e20488ef7..5a188e8646 100644 --- a/crates/mvm-cli/src/commands/vm/exec/run_args_default.rs +++ b/crates/mvm-cli/src/commands/vm/exec/run_args_default.rs @@ -77,6 +77,7 @@ impl Default for RunArgs { plan: None, policy_routes: Vec::new(), applied_policy: None, + project_manifest: None, applied_ai_policy: None, policy_backend: None, review_source: None, diff --git a/crates/mvm-cli/src/commands/vm/run_policy.rs b/crates/mvm-cli/src/commands/vm/run_policy.rs index 51bdf263cf..765f116993 100644 --- a/crates/mvm-cli/src/commands/vm/run_policy.rs +++ b/crates/mvm-cli/src/commands/vm/run_policy.rs @@ -241,6 +241,19 @@ fn launch_flags(args: &RunArgs) -> Result { /// A policy that does not resolve, or a flag that asks for something the /// policy denies, blocks, or bounds — named. pub(in crate::commands) fn apply_run_policy(args: &mut RunArgs) -> Result<()> { + let project = super::run_routes::read_project_manifest(args)?; + // Snapshot absence for any named project source, even when its directory + // does not exist yet. A later-created manifest must not add authority. + args.project_manifest = match project { + Some((path, manifest)) => Some(super::run_routes::ProjectManifestSnapshot::Present( + path, + Box::new(manifest), + )), + None if args.manifest.is_some() || args.flake.is_some() => { + Some(super::run_routes::ProjectManifestSnapshot::Absent) + } + None => None, + }; let selected = select(args)?; let environment = super::exec::application_source::environment(args)?; apply_selected_policy(args, selected, environment) diff --git a/crates/mvm-cli/src/commands/vm/run_routes.rs b/crates/mvm-cli/src/commands/vm/run_routes.rs index 9c6499d0f5..a28ee61ebc 100644 --- a/crates/mvm-cli/src/commands/vm/run_routes.rs +++ b/crates/mvm-cli/src/commands/vm/run_routes.rs @@ -13,6 +13,13 @@ use mvm_client::admission::run_routes::{RunRoutes, resolve_run_routes}; use super::exec::RunArgs; use crate::approval::{ApprovalChoice, ApprovalInputs}; +/// The project manifest observed at policy resolution, including absence. +#[derive(Debug, Clone)] +pub(in crate::commands) enum ProjectManifestSnapshot { + Absent, + Present(std::path::PathBuf, Box), +} + /// Resolve the launch's routes before anything boots. pub(in crate::commands) fn launch_routes(args: &RunArgs) -> Result { let mut routes = project_manifest(args)? @@ -38,6 +45,19 @@ pub(in crate::commands) fn launch_approval(args: &RunArgs) -> Result Result> { + match &args.project_manifest { + Some(ProjectManifestSnapshot::Absent) => Ok(None), + Some(ProjectManifestSnapshot::Present(path, manifest)) => { + Ok(Some((path.clone(), manifest.as_ref().clone()))) + } + None => read_project_manifest(args), + } +} + +/// Read the authored input once, before policy is folded into launch arguments. +pub(in crate::commands) fn read_project_manifest( + args: &RunArgs, ) -> Result> { let path = match (args.manifest.as_deref(), args.flake.as_deref()) { (Some(manifest), _) => { @@ -110,6 +130,77 @@ mod tests { ); } + #[test] + fn launch_uses_the_manifest_that_policy_resolution_read() { + let project = tempfile::tempdir().unwrap(); + let path = project.path().join("mvm.toml"); + std::fs::write( + &path, + "flake = \".\"\n[network]\nallow_hosts = [\"api.example.com:443\"]\n\ + [[network.routes]]\nid = \"first\"\nhost = \"api.example.com\"\nintercept = true\n\ + rules = [{ method = \"GET\", path = \"/first\", outcome = \"allow\" }]\n\ + [approval]\nbackends = [\"deny\"]\n", + ) + .unwrap(); + let mut args = RunArgs { + flake: Some(project.path().display().to_string()), + ..RunArgs::default() + }; + super::super::run_policy::apply_run_policy(&mut args).unwrap(); + + std::fs::write(&path, "flake = \".\"\n").unwrap(); + + let routes = launch_routes(&args).unwrap(); + assert_eq!(routes.routes.len(), 1); + assert_eq!(routes.routes[0].id, "first"); + assert_eq!(args.allow_host, ["api.example.com:443"]); + assert_eq!( + launch_approval(&args).unwrap().backends(), + [crate::approval::ApprovalSpec::Deny] + ); + } + + #[test] + fn a_manifest_created_after_policy_resolution_adds_no_authority() { + let project = tempfile::tempdir().unwrap(); + let mut args = RunArgs { + flake: Some(project.path().display().to_string()), + ..RunArgs::default() + }; + super::super::run_policy::apply_run_policy(&mut args).unwrap(); + std::fs::write( + project.path().join("mvm.toml"), + "flake = \".\"\n[[network.routes]]\nid = \"late\"\nhost = \"example.com\"\nintercept = true\n\ + rules = [{ method = \"GET\", path = \"/\", outcome = \"allow\" }]\n", + ) + .unwrap(); + assert!(launch_routes(&args).unwrap().is_empty()); + } + + #[test] + fn a_project_directory_created_after_policy_resolution_adds_no_authority() { + let parent = tempfile::tempdir().unwrap(); + let project = parent.path().join("later"); + let mut args = RunArgs { + flake: Some(project.display().to_string()), + ..RunArgs::default() + }; + super::super::run_policy::apply_run_policy(&mut args).unwrap(); + std::fs::create_dir(&project).unwrap(); + std::fs::write( + project.join("mvm.toml"), + "flake = \".\"\n[[network.routes]]\nid = \"late\"\nhost = \"example.com\"\nintercept = true\n\ + rules = [{ method = \"GET\", path = \"/\", outcome = \"allow\" }]\n", + ) + .unwrap(); + assert!(launch_routes(&args).unwrap().is_empty()); + assert!( + super::super::run_secrets::project_secret_specs(&args) + .unwrap() + .is_empty() + ); + } + #[test] fn a_run_with_neither_carries_no_routes() { assert!(launch_routes(&RunArgs::default()).unwrap().is_empty()); diff --git a/crates/mvm-cli/src/commands/vm/run_secrets.rs b/crates/mvm-cli/src/commands/vm/run_secrets.rs index 9f1113761c..32a06f9049 100644 --- a/crates/mvm-cli/src/commands/vm/run_secrets.rs +++ b/crates/mvm-cli/src/commands/vm/run_secrets.rs @@ -4,9 +4,7 @@ //! the run surface's one call into it, plus the launch-shape consequence a //! secret-bearing run carries. -use std::path::Path; - -use anyhow::{Context, Result}; +use anyhow::Result; use mvm_client::admission::run_secrets::{ RunSecretSpec, manifest_secret_specs, merge_secret_specs, parse_run_secret_specs, resolve_launch_secrets, @@ -39,21 +37,9 @@ pub(in crate::commands) fn admitted_run_secrets(args: &mut RunArgs) -> Result Result> { - let path = match (args.manifest.as_deref(), args.flake.as_deref()) { - (Some(manifest), _) => { - mvm_core::manifest::resolve_manifest_config_path(Path::new(manifest)).ok() - } - (None, Some(flake)) if Path::new(flake).is_dir() => { - mvm_core::manifest::manifest_in_dir(Path::new(flake))? - } - _ => None, - }; - let Some(path) = path else { - return Ok(Vec::new()); - }; - let manifest = mvm_core::manifest::Manifest::read_file(&path) - .with_context(|| format!("reading {} for its [secrets]", path.display()))?; - Ok(manifest_secret_specs(&manifest)) + Ok(super::run_routes::project_manifest(args)? + .map(|(_, manifest)| manifest_secret_specs(&manifest)) + .unwrap_or_default()) } /// The run's project secrets merged with its `--secret` flags, for a launch @@ -107,6 +93,48 @@ mod tests { assert!(format!("{err:#}").contains("widens"), "{err:#}"); } + #[test] + fn a_manifest_edit_after_policy_resolution_cannot_widen_secret_destinations() { + let project = tempfile::tempdir().unwrap(); + let path = project.path().join("mvm.toml"); + std::fs::write( + &path, + "flake = \".\"\n[secrets]\ngitlab = { hosts = [\"gitlab.com\"] }\n", + ) + .unwrap(); + let mut args = RunArgs { + flake: Some(project.path().display().to_string()), + ..RunArgs::default() + }; + super::super::run_policy::apply_run_policy(&mut args).unwrap(); + + std::fs::write( + &path, + "flake = \".\"\n[secrets]\ngitlab = { hosts = [\"evil.test\"] }\n", + ) + .unwrap(); + + let specs = merged_secret_specs(&args).unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].destinations, ["gitlab.com"]); + } + + #[test] + fn a_new_manifest_after_policy_resolution_cannot_declare_secrets() { + let project = tempfile::tempdir().unwrap(); + let mut args = RunArgs { + flake: Some(project.path().display().to_string()), + ..RunArgs::default() + }; + super::super::run_policy::apply_run_policy(&mut args).unwrap(); + std::fs::write( + project.path().join("mvm.toml"), + "flake = \".\"\n[secrets]\ngitlab = { hosts = [\"gitlab.com\"] }\n", + ) + .unwrap(); + assert!(merged_secret_specs(&args).unwrap().is_empty()); + } + #[test] fn a_run_with_no_local_manifest_declares_no_project_secrets() { let args = RunArgs { diff --git a/crates/mvm-client/src/policy_profiles/resolve.rs b/crates/mvm-client/src/policy_profiles/resolve.rs index 5031549e04..a3ec00a1ec 100644 --- a/crates/mvm-client/src/policy_profiles/resolve.rs +++ b/crates/mvm-client/src/policy_profiles/resolve.rs @@ -11,13 +11,19 @@ //! A cycle is an error that names the loop; so is a chain deeper than //! [`MAX_EXTENDS_DEPTH`]. -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use mvm_contract::protocol::vm_backend::BackendKind; use super::merge::{Layer, ResolvedPolicy, merge_layers}; use super::model::{GroupFile, HostArch, HostOs, PolicyBody, ProfileFile, WhenBlock}; -use super::source::{DocFormat, LayerOrigin, Loaded, PolicyError, PolicyRef, PolicyStore}; +use super::source::{ + DocFormat, Kind, LayerOrigin, Loaded, PolicyError, PolicyRef, PolicyStore, parse_document, + source_sha256, +}; /// The deepest `extends` chain accepted. pub const MAX_EXTENDS_DEPTH: usize = 10; @@ -145,15 +151,7 @@ pub fn resolve( selection: &PolicySelection, platform: Platform, ) -> Result { - let mut walker = Walker { - store, - platform, - stack: Vec::new(), - visited: Vec::new(), - groups: Vec::new(), - overrides: Vec::new(), - backend_conditioned: false, - }; + let mut walker = Walker::new(store, platform); for reference in &selection.profiles { match store.load_profile(reference, None, LayerOrigin::User, "--policy") { Ok(root) => walker.walk(root, 0)?, @@ -164,6 +162,7 @@ pub fn resolve( Err(profile_error) => { match store.load_group(reference, None, LayerOrigin::User, "--policy") { Ok(group) => { + walker.record_source(&group)?; if !walker.groups.iter().any(|g| g.identity == group.identity) { walker.groups.push(SelectedGroup { identity: group.identity.clone(), @@ -212,16 +211,13 @@ pub fn resolve( label: format!("project {}", project.manifest.display()), identity: format!("project:{}", project.manifest.display()), origin: LayerOrigin::Project, + source_sha256: None, + reference_key: None, }, 0, )?; } - let backend_conditioned = walker.backend_conditioned; - let mut layers: Vec = walker.groups.into_iter().map(|g| g.layer).collect(); - layers.extend(walker.overrides); - let mut resolved = merge_layers(&layers)?; - resolved.backend_conditioned = backend_conditioned; - Ok(resolved) + walker.finish() } /// Resolve a single file that is a profile or, failing that, a group, as a @@ -239,24 +235,45 @@ pub fn resolve_file( let text = std::fs::read_to_string(path).map_err(|error| { PolicyError::new(path.display().to_string(), format!("reading: {error}")) })?; - if DocFormat::for_path(path) - .parse::(&text) - .is_ok() - { - return resolve( - store, - &PolicySelection::profile(PolicyRef::Path(path.to_path_buf())), - platform, - ); + resolve_file_text(store, path, platform, &text) +} + +fn resolve_file_text( + store: &PolicyStore, + path: &Path, + platform: Platform, + text: &str, +) -> Result { + let identity = std::fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .display() + .to_string(); + if let Ok(doc) = DocFormat::for_path(path).parse::(text) { + let mut walker = Walker::new(store, platform); + walker.walk( + Loaded { + doc, + file: Some(path.to_path_buf()), + label: format!("profile {}", path.display()), + identity, + origin: LayerOrigin::User, + source_sha256: Some(source_sha256(text)), + reference_key: None, + }, + 0, + )?; + return walker.finish(); } - let group = store.load_group( - &PolicyRef::Path(path.to_path_buf()), - None, - LayerOrigin::User, - "file", - )?; - let layers = vec![group_layer(&group)]; - merge_layers(&layers) + let doc = parse_document::(text, path, Kind::Group)?; + merge_layers(&[group_layer(&Loaded { + doc, + file: Some(path.to_path_buf()), + label: format!("group {}", path.display()), + identity, + origin: LayerOrigin::User, + source_sha256: Some(source_sha256(text)), + reference_key: None, + })]) } struct SelectedGroup { @@ -271,6 +288,8 @@ struct Walker<'a> { platform: Platform, stack: Vec<(String, String)>, visited: Vec, + seen_sources: BTreeMap, + seen_lookups: BTreeMap, groups: Vec, overrides: Vec, backend_conditioned: bool, @@ -289,8 +308,73 @@ fn base_dir(file: Option<&Path>) -> Option<&Path> { file.and_then(Path::parent) } -impl Walker<'_> { +impl<'a> Walker<'a> { + fn new(store: &'a PolicyStore, platform: Platform) -> Self { + Self { + store, + platform, + stack: Vec::new(), + visited: Vec::new(), + seen_sources: BTreeMap::new(), + seen_lookups: BTreeMap::new(), + groups: Vec::new(), + overrides: Vec::new(), + backend_conditioned: false, + } + } + + fn finish(self) -> Result { + let mut layers: Vec = self.groups.into_iter().map(|g| g.layer).collect(); + layers.extend(self.overrides); + let mut resolved = merge_layers(&layers)?; + resolved.backend_conditioned = self.backend_conditioned; + Ok(resolved) + } + + fn record_source(&mut self, loaded: &Loaded) -> Result<(), PolicyError> { + let Some(digest) = loaded.source_sha256 else { + return Ok(()); + }; + if let Some(key) = &loaded.reference_key + && self + .seen_lookups + .get(key) + .is_some_and(|(identity, previous)| { + identity != &loaded.identity || *previous != digest + }) + { + return Err( + PolicyError::new(&loaded.label, "source changed while resolving policy") + .in_file(loaded.file.as_deref()), + ); + } + let identity = loaded.file.as_ref().map_or_else( + || loaded.identity.clone(), + |path| { + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()); + format!("file:{}", path.display()) + }, + ); + if self + .seen_sources + .get(&identity) + .is_some_and(|previous| *previous != digest) + { + return Err( + PolicyError::new(&loaded.label, "source changed while resolving policy") + .in_file(loaded.file.as_deref()), + ); + } + self.seen_sources.insert(identity, digest); + if let Some(key) = &loaded.reference_key { + self.seen_lookups + .insert(key.clone(), (loaded.identity.clone(), digest)); + } + Ok(()) + } + fn walk(&mut self, profile: Loaded, depth: usize) -> Result<(), PolicyError> { + self.record_source(&profile)?; if let Some(position) = self .stack .iter() @@ -389,6 +473,7 @@ impl Walker<'_> { for raw in include { let group = self.load_group(profile, raw, base.as_deref(), &format!("{key}.include"))?; + self.record_source(&group)?; if self.groups.iter().any(|g| g.identity == group.identity) { continue; } @@ -402,6 +487,7 @@ impl Walker<'_> { for raw in exclude { let group = self.load_group(profile, raw, base.as_deref(), &format!("{key}.exclude"))?; + self.record_source(&group)?; let Some(position) = self .groups .iter() diff --git a/crates/mvm-client/src/policy_profiles/resolve_tests.rs b/crates/mvm-client/src/policy_profiles/resolve_tests.rs index 45627e2367..242de5ca71 100644 --- a/crates/mvm-client/src/policy_profiles/resolve_tests.rs +++ b/crates/mvm-client/src/policy_profiles/resolve_tests.rs @@ -516,6 +516,97 @@ fn a_root_reference_that_is_neither_profile_nor_group_reports_the_profile_error( assert!(err.file.as_deref() == Some(bad.as_path()), "{rendered}"); } +#[test] +fn resolve_file_uses_the_profile_bytes_it_classified() { + let dir = Dir::new(); + let path = dir.profile("candidate", "[overrides.network]\nallow = [\"old.test\"]\n"); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, "[overrides.network]\nallow = [\"new.test\"]\n").unwrap(); + + let resolved = resolve_file_text(&dir.store, &path, Platform::default(), &text).unwrap(); + assert_eq!(allow(&resolved), ["old.test:443"]); +} + +#[test] +fn resolve_file_uses_the_group_bytes_it_classified() { + let dir = Dir::new(); + let path = dir.store.groups_dir().join("candidate.toml"); + std::fs::write(&path, "[network]\nallow = [\"old.test\"]\n").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, "[network]\nallow = [\"new.test\"]\n").unwrap(); + + let resolved = resolve_file_text(&dir.store, &path, Platform::default(), &text).unwrap(); + assert_eq!(allow(&resolved), ["old.test:443"]); +} + +#[test] +fn the_same_profile_cannot_change_between_reads_in_one_resolution() { + let dir = Dir::new(); + let path = dir.profile("candidate", "[overrides.network]\nallow = [\"old.test\"]\n"); + let reference = PolicyRef::Name("candidate".into()); + let mut walker = Walker::new(&dir.store, Platform::default()); + let first = dir + .store + .load_profile(&reference, None, LayerOrigin::User, "test") + .unwrap(); + walker.walk(first, 0).unwrap(); + std::fs::write(&path, "[overrides.network]\nallow = [\"new.test\"]\n").unwrap(); + let second = dir + .store + .load_profile(&reference, None, LayerOrigin::User, "test") + .unwrap(); + let err = walker.walk(second, 0).unwrap_err(); + assert!(err.to_string().contains("changed while resolving"), "{err}"); +} + +#[test] +fn an_excluded_group_cannot_change_between_reads_in_one_resolution() { + let dir = Dir::new(); + let group = dir.store.groups_dir().join("candidate.toml"); + std::fs::write(&group, "[network]\nallow = [\"old.test\"]\n").unwrap(); + let profile = Loaded { + doc: ProfileFile::default(), + file: None, + label: "test profile".into(), + identity: "test profile".into(), + origin: LayerOrigin::User, + source_sha256: None, + reference_key: None, + }; + let mut walker = Walker::new(&dir.store, Platform::default()); + walker + .select(&profile, &["candidate".into()], &[], "groups") + .unwrap(); + std::fs::write( + &group, + "required = true\n[network]\nallow = [\"new.test\"]\n", + ) + .unwrap(); + let err = walker + .select(&profile, &[], &["candidate".into()], "groups") + .unwrap_err(); + assert!(err.to_string().contains("changed while resolving"), "{err}"); +} + +#[test] +fn a_new_user_profile_cannot_shadow_a_built_in_during_resolution() { + let dir = Dir::new(); + let reference = PolicyRef::Name("offline".into()); + let mut walker = Walker::new(&dir.store, Platform::default()); + let builtin = dir + .store + .load_profile(&reference, None, LayerOrigin::User, "test") + .unwrap(); + walker.walk(builtin, 0).unwrap(); + dir.profile("offline", "[overrides.network]\nallow = [\"new.test\"]\n"); + let user = dir + .store + .load_profile(&reference, None, LayerOrigin::User, "test") + .unwrap(); + let err = walker.walk(user, 0).unwrap_err(); + assert!(err.to_string().contains("changed while resolving"), "{err}"); +} + #[test] fn a_project_table_resolves_its_profile_and_groups_as_project_layers() { let dir = Dir::new(); diff --git a/crates/mvm-client/src/policy_profiles/source.rs b/crates/mvm-client/src/policy_profiles/source.rs index a68abce38b..da0cb5d3f9 100644 --- a/crates/mvm-client/src/policy_profiles/source.rs +++ b/crates/mvm-client/src/policy_profiles/source.rs @@ -13,6 +13,7 @@ use std::path::{Path, PathBuf}; use serde::Serialize; +use sha2::{Digest, Sha256}; use super::builtin; use super::model::{GroupFile, ProfileFile}; @@ -220,6 +221,14 @@ pub struct Loaded { /// A stable identity for cycle detection and de-duplication. pub identity: String, pub origin: LayerOrigin, + /// Digest of the exact document bytes parsed; absent for virtual layers. + pub(crate) source_sha256: Option<[u8; 32]>, + /// The lookup spelling, so a named source cannot switch to a different file. + pub(crate) reference_key: Option, +} + +pub(crate) fn source_sha256(text: &str) -> [u8; 32] { + Sha256::digest(text.as_bytes()).into() } /// The directories names resolve in. @@ -398,6 +407,8 @@ impl PolicyStore { verified.manifest_sha256().as_str() ), origin: LayerOrigin::Pack, + source_sha256: Some(source_sha256(&text)), + reference_key: Some(format!("{noun}:{spelling}")), }) } PolicyRef::Path(path) => { @@ -415,13 +426,15 @@ impl PolicyStore { .unwrap_or_else(|_| path.clone()) .display() .to_string(); - let doc = read_doc(&path, kind, referrer)?; + let (doc, digest) = read_doc(&path, kind, referrer)?; Ok(Loaded { doc, label: format!("{} {}", kind.noun(), path.display()), file: Some(path), identity, origin: path_origin, + source_sha256: Some(digest), + reference_key: None, }) } PolicyRef::Name(name) => { @@ -438,13 +451,15 @@ impl PolicyStore { ), )); } - let doc = read_doc(&user_file, kind, referrer)?; + let (doc, digest) = read_doc(&user_file, kind, referrer)?; return Ok(Loaded { doc, label: format!("{} `{name}`", kind.noun()), file: Some(user_file), identity: format!("user:{}:{name}", kind.noun()), origin: LayerOrigin::User, + source_sha256: Some(digest), + reference_key: Some(format!("{}:{name}", kind.noun())), }); } let text = builtin(name).ok_or_else(|| { @@ -467,6 +482,8 @@ impl PolicyStore { file: None, identity: format!("builtin:{}:{name}", kind.noun()), origin: LayerOrigin::Builtin, + source_sha256: Some(source_sha256(text)), + reference_key: Some(format!("{}:{name}", kind.noun())), }) } } @@ -474,7 +491,7 @@ impl PolicyStore { } #[derive(Debug, Clone, Copy)] -enum Kind { +pub(crate) enum Kind { Profile, Group, } @@ -500,15 +517,23 @@ fn read_doc( path: &Path, kind: Kind, referrer: &str, -) -> Result { - let format = DocFormat::for_path(path); +) -> Result<(T, [u8; 32]), PolicyError> { let text = std::fs::read_to_string(path).map_err(|error| { PolicyError::new( referrer, format!("reading {} {}: {error}", kind.noun(), path.display()), ) })?; - format.parse(&text).map_err(|message| { + Ok((parse_document(&text, path, kind)?, source_sha256(&text))) +} + +pub(crate) fn parse_document( + text: &str, + path: &Path, + kind: Kind, +) -> Result { + let format = DocFormat::for_path(path); + format.parse(text).map_err(|message| { let error = PolicyError::new( format!("{} {} ({})", kind.noun(), path.display(), format.noun()), message.clone(), diff --git a/crates/mvm-core/src/host_interface/mod.rs b/crates/mvm-core/src/host_interface/mod.rs index c947049b0d..5146b9dc7b 100644 --- a/crates/mvm-core/src/host_interface/mod.rs +++ b/crates/mvm-core/src/host_interface/mod.rs @@ -4,8 +4,8 @@ //! The local adapter ([`local::LocalHostInterface`]) is the default and needs //! no central identity, `mvmd` or hardware attestation. A managed //! implementation of the same [`HostInterface`] trait lives with `mvmd`. -//! Nothing here is wired into the launch path yet: this slice fixes the -//! contract, the fail-closed preflight and the state/epoch rules. +//! Local plan admission invokes this interface's preflight before a workload +//! starts. It does not own the full launch or a running workload's lifecycle. pub mod error; pub mod lease; diff --git a/public/src/content/docs/guides/policy-and-profiles.md b/public/src/content/docs/guides/policy-and-profiles.md index a16ceaa655..39bc528b2f 100644 --- a/public/src/content/docs/guides/policy-and-profiles.md +++ b/public/src/content/docs/guides/policy-and-profiles.md @@ -92,6 +92,13 @@ resolved manifest, not a plan: the file is re-validated when it is read, and admission synthesizes and signs the plan itself. A file that carries an execution plan or a signature is refused. +For a single `run` or `machine run`, the project's `mvm.toml` is parsed once +for its authored policy, endpoint routes, approval backends, and secret +declarations. Editing it after policy resolution starts does not change those +choices partway through that launch. This is launch-time consistency, **not** +an atomic policy apply: later launches still read authored files, and running +workloads are not rolled over by an edit. + ## Groups ```toml diff --git a/specs/adrs/111-host-trusted-evidence-freshness.md b/specs/adrs/111-host-trusted-evidence-freshness.md new file mode 100644 index 0000000000..d00c249aa4 --- /dev/null +++ b/specs/adrs/111-host-trusted-evidence-freshness.md @@ -0,0 +1,328 @@ +# ADR-111: Host-trusted evidence freshness across workload transitions + +Backing: preview +Validation: none + +## Status + +Proposed for review in [#5386](https://github.com/tinylabscom/mvm/issues/5386). +The operator has selected the separate, automatically managed local lifecycle +coordinator described below; the other transitions remain proposed. This +record defines a design boundary, not a shipped re-attestation capability +or an amendment to the claims in ADR-001. + +## Context + +MVM is a verifiable, secure execution platform. Its backends run workloads, +while the signed plan, policy admission, host enforcement, and audit evidence +determine what a particular execution was authorized to do and what can be +checked later. Validating an old signature does not establish that its policy +is still in force, that its workload is still running, or that a resumed guest +is in the state the signature once described. + +The current paths have different semantics. A durable agent-session resume +verifies its resume point, synthesizes and admits a new plan, and advances the +session generation. A checkpoint revert launches a fresh, re-admitted VM. A +paused instance's snapshot resume verifies its snapshot and replay epoch and, +on the sealed Firecracker path, waits for post-restore reseeding before marking +the guest admitted; this is not the same operation as creating a new signed +plan. The `trust attest` export is a host-signed identity report with a zero +boot-measurement sentinel, and its verifier checks signer trust and signature, +not the freshness of a measured workload state. + +These are useful but distinct kinds of evidence. Treating any one as a +universal "currently attested" result would strengthen the guarantee without +a corresponding enforcement path or witness. + +## Proposed decision + +### Name the claim before checking its freshness + +Every verifier-facing result must state what it checked: + +| Evidence | What it can establish today | What it cannot establish by itself | +| --- | --- | --- | +| Signed execution plan and admission record | A trusted host admitted a particular plan, at that decision point, under the then-applicable policy | That later actions remained authorized after revocation or policy change | +| Digest-pinned artifact and snapshot integrity | The checked bytes match an authenticated identity, with the snapshot replay and restore checks appropriate to its backend | That those bytes are currently admitted, running, or ready | +| Chain-signed audit entry or evidence archive | The recorded event is attributable to the trusted host; full-chain archive coverage is verifier-derivable where available | An event that the host never recorded, or a missing tail of the host's log | +| Host attestation identity report | A trusted host key signed the report body | Measured guest boot, verifier-challenged freshness, or protection from a malicious host | + +Do not infer one row from another. In particular, a newly signed report over a +sentinel measurement is not fresh proof of the running workload, and a verified +build is not an admitted replacement. + +### Freshness is an operation-specific decision + +The local admission owner remains authoritative. The party requesting an +effect presents the relevant plan or capability; the host resolves current +policy and checks expiry, revocation, generation, and required backend +enforcement before granting that effect. A cached artifact or successful +signature check cannot renew authorization. Managed cross-host transport may +convey evidence, but does not create a second local authority. There is no +single default duration that turns every signed artifact into authority. + +The following is an inventory and intended review boundary, **not** a claim +that every transition is already enforced end to end: + +| Transition | Prior evidence that may become stale | Required review before protected effects | Failure and recovery | +| --- | --- | --- | --- | +| New launch or replacement | Prior workload's image, plan, generation and grants | Resolve the replacement's exact artifact and current policy; sign and admit its own plan; verify backend readiness before cutover | Keep the old workload only while its own enforcement remains trustworthy and authorized; do not promote a built candidate on verification, admission or readiness failure | +| Durable agent-session resume | Prior residency's plan, approval head and checkpoint | Verify checkpoint integrity and current approval head; admit a new plan for the next session generation | Refuse without advancing the session on a failed check; a repeated identical transition does not admit a second plan | +| Paused-instance snapshot resume | Prior snapshot epoch, guest entropy state and any authority that expired while paused | Preserve snapshot/replay and reseed checks; separately determine whether the original plan and effect grants remain authorized before permitting their use | Refuse or isolate effects when authority cannot be established; stop an unadmitted restored guest and make interrupted recovery unambiguous | +| Applied policy change, lease expiry or revocation | Earlier grant or admitted policy projection | Withdraw the old authority from affected running instances; use the existing policy owner and applicable per-effect gate, not network reachability or an old signature, to authorize later effects | Stop an instance if its changed permissions cannot be enforced in place; record the decision without exposing payloads or secrets | +| Workload termination | Any evidence of a live instance | No new effect under the terminated instance's identity; a successor needs its own admission and epoch where applicable | Distinguish termination from a lost heartbeat or unknown state; do not infer physical erasure | + +Each implementation must name its transition owner, durable audit evidence, +decision deadline, cancellation behavior, crash recovery, and typed refusal. +Rechecking only when an operator asks to export evidence cannot substitute +for enforcement before a protected action. A versioned format migration is +required if the signed plan or wire meaning changes; [#5158](https://github.com/tinylabscom/mvm/issues/5158) +owns the separate decision on a plan-borne execution epoch. + +### Trust boundary and witnesses + +This first track protects against an untrusted workload and stale or replayed +authorization on a **trusted host**. The host holds the signing keys and +controls launch material. Neither host signatures nor optional host TPM +measurements provide an independently qualified malicious-host boundary. +Verifier-issued challenges, genuine boot measurements, hardware key custody, +and remote verification against a compromised host require separate +contracts and witnesses before such claims can ship. + +For the first implemented transition, tests must witness a successful renewal +or refusal at the production admission/effect boundary, not merely validate +a declaration. Include stale policy or epoch, replay, incorrect principal or +artifact, missing enforcement, cancellation, and recovery as applicable to +that transition. Keep mock evidence labeled mock; a live-backend claim needs +a live-backend witness. Preserve end-to-end launch and readiness measurement +including admission. + +## Consequences and next review + +The current signed-plan, snapshot, lease, and mailbox contracts remain the +sources of authority. This proposal adds no new independent verifier, extra +guest network path, or mandatory TPM. + +### Atomic policy apply + +The operator chose an atomic apply boundary. Editing an authoring file is +staging, not publication of a policy in force. A proposed +`mvmctl policy apply` operation would validate the complete candidate and +its dependencies before changing any running authority. Launch and restore +must consume the committed policy generation, not whichever authoring bytes +happen to be on disk. The operation cannot report success until every +affected live instance is replaced or safely bound to the new signed +admission and the new generation is durable. + +The host-side admission owner must serialize apply with launches, resumes, +and other applies. It needs a durable binding from each live workload to +the policy inputs and effective generation it was admitted under. An apply +that cannot identify or fence every affected workload refuses publication; +it must not guess from a VM's name or re-read a mutable policy file and +call that the already-admitted policy. A default or project policy, a +named profile/group, and a tenant bundle are different input sources, +but must resolve through the same policy owner. An unsupported source +cannot silently opt out of live policy updates. + +This owner is local to MVM, not `mvmd`. Today the CLI calls +`mvm-hostd::plan_admission` and the selected backend directly rather than +handing the whole boot to a resident supervisor (ADR-014). `mvmctl policy` +only resolves, shows, validates and compares authoring inputs; a +`ResolvedPolicy` lists contributing files but carries no committed +generation. The per-VM network endpoint creates a static egress projection. +The supervisor's `hot_revision_replaces_firewall_for_same_workload` is a +mock test that resets its state machine between two launches; it is not +a production policy-cutover caller. None of these is already the +single-owner apply transaction. Implementing a policy-generation type +without connecting launch, effect fencing and recovery to that owner +would add a dormant control, not this capability. +The local host-interface adapter also loads +`/host-interface/policy.json` directly at admission; that +host-level policy is distinct from a workload's `mvm.toml`, named profiles +and groups. Versioning that one file alone would not stage or apply the +policies that ordinary workload launches resolve. The first production +slice must identify which authoring source it covers, and unsupported +sources must not be reported as covered by its atomicity claim. +The current local admission path already records an `AssetKind::Policy` +identity for the resolved network policy in the signed plan. Reuse that +effective-policy evidence; it is not a committed generation or a digest +of every contributing project/profile/group file. Neither new source +binding nor an apply transaction can be inferred from that asset alone. +Hashing authoring files *after* resolving them is not enough: a concurrent +edit could bind a digest of bytes the resolver never used. Capture and +verify one exact source closure at the resolution/commit boundary, then +admit from that immutable closure rather than re-reading loose files. +The closure must include the project's manifest as parsed, every profile +and group document actually inspected (including an excluded group's +`required` bit), the resolved identity of built-in and verified-pack +references, the selected default and explicit policy references, and the +backend/platform predicates used for conditional layers. Record the bytes +at each parser read, not a later read of its path. A missing user file +whose absence selected a built-in must not later shadow that committed +selection. A path, digest, and effective policy alone are insufficient +if launch still follows mutable names or filesystem links. The first +versioned format must preserve these identities and refuse unknown +fields or an incomplete closure; its concrete encoding and migration +belong to the implementing contract. + +The selected owner is the existing **local host-service boundary**: +move lifecycle policy admission and cutover coordination behind +`mvm-hostd`, exposed to CLI and SDK through the existing client contract. +Keep backend-specific prepare, stop and handoff under their existing +backend contracts. A one-shot CLI process with a file lock cannot fence +an already-running endpoint or recover an interrupted handoff after it +exits; delegating local authority to `mvmd` would break standalone +execution. The remaining transaction and backend contracts still need +their own implementation and witnesses before this can ship. +`mvmctl` must start or connect to the local coordinator automatically; +operators must not run a second daemon-start command. A coordinator +failure refuses an unsafe apply instead of falling back to one-shot +authorization. +The resident `mvm-host-agent` currently registers VMs and serves host +services; it does **not** own their launches or policy cutovers. Use a +distinct local lifecycle coordinator +within the `mvm-hostd` boundary, auto-started or contacted by `mvmctl`. +Do not grant the per-tenant, keyless `mvm-host-agent` authority over host +launches merely because it is already resident: it parses guest traffic +and runs under tenant containment. The coordinator must preserve the +separate broker and signer key-custody boundary, keep durable local state, +and recover after CLI exit. It is not a new cloud service or a dependency +on `mvmd`. + +This coordinator is an implementation of [#5097](https://github.com/tinylabscom/mvm/issues/5097)'s +local host-interface responsibilities, not a second authority alongside +`HostInterface`. Local mode resolves and applies locally without central +admission or mandatory hardware attestation; managed mode must accept only +the policy, epochs and lease authority its authenticated `mvmd` provider +supplies. The same client operation may drive both without making `mvmd` +a prerequisite for local launch. A managed node that is disconnected +cannot claim a newly published remote policy took effect immediately: +its signed offline lease bounds already-authorized operations and expiry, +and reconciliation is required before fresh authority is granted. +Atomicity here is **per local host transition**, not an instantaneous +fleet-wide commit across unreachable nodes. + +### Prepare, then roll over + +Prefer a prepared replacement to a cold stop. The old workload may continue +under the old committed generation **while the candidate is being built**. +The candidate can have its image and runtime verified. If it initializes +early, its own signed launch preflight and isolation must already hold; it +has no egress, secrets, writable volume, mailbox authority, or +production-readiness claim before its applicable admission checks complete. +Preparing it is not publishing the new policy. The old and new workloads +must not both hold active authority over the same identity, mailbox, or +effect route. + +The transaction has one externally visible authority switch, after +preparation: + +1. Parse, resolve and validate the candidate against the host ceiling; + retain its exact source identities and digest. Prepare the replacement + without granting external authority. If preparation fails, the old + workload remains on its still-authorized policy; apply fails. +2. Lock the policy generation and the affected workload transitions. + Reconcile running and paused instances; refuse if an affected instance + cannot be identified or controlled. +3. Fence the old generation from *new* effects, then drain or cancel + in-flight effects as the changed policy requires. A timeout or uncertain + delivery is not a successful drain. Preserve accepted, still-authorized + mailbox messages; acceptance, delivery and application acknowledgement + are distinct. If the old workload's enforcement is untrustworthy, stop + it rather than trying to keep it serving through cutover. +4. Complete the candidate's signed-plan admission under the new generation, + check backend enforcement and readiness, and durably record the source + digest, affected instances and outcome. Switch the host-owned route or + mailbox owner to that candidate only when the old authority is fenced + and the new authority is ready. Stop the old instance after ownership + moves. +5. Release transition locks only after the committed generation and + single active owner agree. On a crash, keep both sides' external effects + fenced until durable state decides the owner; do not replay a success + response from an incomplete transition. + +Before the authority switch, candidate failure may leave the old workload +running only while its former policy is still authorized. After the new +generation is committed, restarting the old workload under its former +grant is not rollback: failure to activate the candidate must refuse +effects and report an unavailable/unknown outcome. This procedure aims +to minimize interruption, not promise zero downtime for stateful work or +on a backend without a safe handoff. The exact cutover must be witnessed +on each advertised backend. + +The first implementation must connect three ownership edges before +advertising `policy apply`: (1) admission records the exact source closure +and committed generation for each affected instance, (2) every launch and +restore reads that committed generation rather than staged files, and +(3) the host can fence an old instance's already-open external effects, +transfer its host-owned route or mailbox, and reconcile an interrupted +handoff. A candidate's readiness is not merely a successful build or a +mock supervisor launch. A backend or policy source without those edges +must refuse apply, not pretend to roll over. + +### Backend acceptance sequence + +The first restricted implementation targets a local project `mvm.toml` +workload on Firecracker. HVF follows as the next claim-bearing workload +backend; libkrun follows under its own backend witness. QEMU's dev/test +tier and the explicit Wasm/browser portability tiers are also in the +eventual backend scope, but retain their existing tier limits: policy +rollover does not confer claim-10 egress on QEMU or microVM isolation on +Wasm. A backend without a safe live handoff must refuse that operation +until its own supported lifecycle implements the same atomic authority +switch; no backend may silently use an old grant or downgrade to another +runtime. Unsupported in the first release is an open delivery item, not +an acceptable final product state. Do not promote an all-backend claim +from a Firecracker or mock-only witness. + +This is the *proposed* operation, not a description of current behavior. +If a direct edit could still be read on a launch before apply, or an +unobserved old endpoint could still forward after apply succeeds, the +atomic promise would be false. During migration, refuse an unsupported +affected launch or apply rather than silently accepting that gap. + +The first candidate for #5386 is paused-instance snapshot resume. The local +client's `resume_sealed` verifies the snapshot and records the guest's +post-restore reseed, but does not itself re-resolve plan or policy authority. +The per-VM network endpoint constructs one egress gate from its admitted +policy at endpoint assembly. It checks each new destination against that +gate, not against a newly loaded policy file. A request-time destination +check therefore cannot by itself notice a policy change that happened while +the instance was paused. The `fc.admitted` marker means the restored guest +confirmed its reseed; it is not a newly signed plan. + +The operator requires policy changes to affect **already-running** workloads, +not only their next admission. Recommend **both boundaries, for different +checks**: before restoring vCPUs, the existing admission owner must establish +that the prior instance's authority is still usable. For a running instance, +the host must withdraw affected authority on a policy change; the existing +network, secret and mailbox gates continue to decide each protected effect. +Rechecking a destination against the same static egress gate does not +refresh its authorization. If a prepared replacement or safe handoff is +unavailable, do not report a successful apply while the old workload can +still act under invalid authority. Stop it and refuse or report an +unavailable outcome; do not silently mutate the gate behind an unchanged +plan. + +An offline lease is an explicit signed exception only for the operations +and expiry it already permits. The plan's short validity window gates +*launch admission*; checking its `valid_until` again on resume would +incorrectly turn it into a workload lease. Arbitrary external edits to +project `mvm.toml`, named profiles, and tenant bundles are currently read +at launch, without a common resident file-to-instance binding. A watcher +learns of an edit after it happens and cannot substitute for the atomic +apply owner. There is no current `policy apply` command or committed policy +generation; adoption requires a reviewed public command, provenance and +crash-recovery design before any security claim can change. + +Before implementing the resume preflight, trace whether its endpoint and +signed plan survive pause on each supported backend, and which policy or +revocation sources actually declare mid-lifetime invalidation. Do not add a +second policy owner, mark an unverified guest ready, +or claim a revoked instance was contained while it could still act. If +the production path has no missing enforcement under its declared +contract, select another evidenced transition instead of adding a +redundant gate. + +Promotion from proposed to accepted requires review of the selected +transition, its owner, its refused states, and its observable evidence.