Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/mvm-cli/src/commands/vm/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@ pub(in crate::commands) struct RunArgs {
/// surfaces cannot reopen something it denied.
#[arg(skip)]
pub applied_policy: Option<mvm_client::policy_profiles::PolicyBody>,
/// 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<super::run_routes::ProjectManifestSnapshot>,
/// Internal: effective AI limits retained from the ordinary policy fold.
#[arg(skip)]
pub applied_ai_policy: Option<mvm_core::network_policy::AiPolicy>,
Expand Down
1 change: 1 addition & 0 deletions crates/mvm-cli/src/commands/vm/exec/run_args_default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions crates/mvm-cli/src/commands/vm/run_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,19 @@ fn launch_flags(args: &RunArgs) -> Result<LaunchFlags> {
/// 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)
Expand Down
91 changes: 91 additions & 0 deletions crates/mvm-cli/src/commands/vm/run_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<mvm_core::manifest::Manifest>),
}

/// Resolve the launch's routes before anything boots.
pub(in crate::commands) fn launch_routes(args: &RunArgs) -> Result<RunRoutes> {
let mut routes = project_manifest(args)?
Expand All @@ -38,6 +45,19 @@ pub(in crate::commands) fn launch_approval(args: &RunArgs) -> Result<ApprovalCho
/// no local file.
pub(in crate::commands) fn project_manifest(
args: &RunArgs,
) -> Result<Option<(std::path::PathBuf, mvm_core::manifest::Manifest)>> {
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<Option<(std::path::PathBuf, mvm_core::manifest::Manifest)>> {
let path = match (args.manifest.as_deref(), args.flake.as_deref()) {
(Some(manifest), _) => {
Expand Down Expand Up @@ -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());
Expand Down
64 changes: 46 additions & 18 deletions crates/mvm-cli/src/commands/vm/run_secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -39,21 +37,9 @@ pub(in crate::commands) fn admitted_run_secrets(args: &mut RunArgs) -> Result<Re
/// directory. A registered manifest name or a remote flake has no local file
/// to read, and declares none here.
pub(in crate::commands) fn project_secret_specs(args: &RunArgs) -> Result<Vec<RunSecretSpec>> {
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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading