Skip to content
1,899 changes: 1,899 additions & 0 deletions CHAOS_TEST_6H_20261112.md

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion crates/pvisor-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,19 @@ retaining the completed Run Bundle and completion receipt; it does not rerun the
workload or undo its side effects. Failures before a RunResult exists do not
produce a result file.

The result destination must resolve outside this Session's storage directory,
including an explicit `--stage` directory. Session storage is opaque: exports
cannot target its Bundle, completion receipt, upper, preimages or an arbitrary
subdirectory. This is rejected before starting the workload or publishing Session
artifacts; preflight may leave an empty storage directory that can be reused.
Choose an external directory instead. External files named `run-bundle.json` or
`completion.json`, and siblings such as `stage2` beside `stage`, remain valid.
Existing path ancestors are resolved for admission, not reserved against later
concurrent path rebinding.

Prepared JSON `--spec` continues to require `--result-file` and retains its
existing `DelegatedRunOutput` envelope containing `result` and `agentctl`.
existing `DelegatedRunOutput` envelope containing `result` and `agentctl`. It
uses the same outside-Session-storage destination rule.

`apply SOURCE` defaults to the recorded project. SOURCE is required: a Session ID,
stage directory, workspace selector (including `last`), or staged-bind instance
Expand Down
168 changes: 166 additions & 2 deletions crates/pvisor-cli/src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ pub struct RunArgs {
#[arg(long)]
no_agent_defaults: bool,

/// Atomically write the finalized RunResult as JSON before exit; required with --spec.
/// Atomically write the finalized RunResult outside Session storage; required with --spec.
#[arg(long, value_name = "FILE")]
result_file: Option<PathBuf>,

Expand Down Expand Up @@ -1017,6 +1017,8 @@ async fn run_prepared_spec(args: RunArgs) -> anyhow::Result<i32> {
)
.context("decode delegated RunSpec")?;
reject_ask_policies(&spec.policies, &Default::default())?;
let storage = resolve_run_storage(&stage_path)?;
validate_result_file_destination(&result_path, &storage)?;
let mut stage_guard = None;
let (pvisor, config) = {
let cleanup = args.stage.is_none();
Expand Down Expand Up @@ -1072,7 +1074,6 @@ async fn run_prepared_spec(args: RunArgs) -> anyhow::Result<i32> {
config.record.destination.is_none(),
"JSON --spec does not accept recording overrides"
);
let storage = resolve_run_storage(&stage_path)?;
#[cfg(feature = "gateway")]
let proxy = resolve_proxy(&config)?;
let builder = configured_runtime_builder(
Expand Down Expand Up @@ -1454,6 +1455,9 @@ async fn execute_config(
super::host_service::reject_guest_exposure(sources)?;
}
let storage = resolve_run_storage(&select_run_storage(&config, &workspace, &run_id)?)?;
if let Some(path) = &result_file {
validate_result_file_destination(path, &storage)?;
}
if config
.policies
.scopes()
Expand Down Expand Up @@ -2949,6 +2953,44 @@ pub(super) fn canonical_candidate(path: &Path) -> anyhow::Result<PathBuf> {
Ok(resolved)
}

/// Session storage is opaque: optional frontend exports must not write into it.
/// `storage` is already canonical. Resolve ordinary aliases without creating the
/// export path; this is admission, not a reservation against concurrent rebinding.
fn validate_result_file_destination(path: &Path, storage: &Path) -> anyhow::Result<()> {
let mut prefix = path.to_path_buf();
let mut suffix = Vec::new();
let mut destination = loop {
match canonical_candidate(&prefix) {
Ok(candidate) => break candidate,
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotADirectory) =>
{
// An external file blocking the export's parent must retain its
// existing post-completion write error, not prevent the guest.
// Resolve that file's identity to check storage containment only.
suffix.push(
prefix
.file_name()
.context("result destination has no file name")?
.to_owned(),
);
prefix.pop();
}
Err(error) => return Err(error).context("resolve --result-file destination"),
}
};
destination.extend(suffix.into_iter().rev());
anyhow::ensure!(
!destination.starts_with(storage),
"--result-file must be outside Session storage: {} is inside {}",
path.display(),
storage.display()
);
Ok(())
}

fn resolve_default_vm_rootfs(config: &mut RunConfig) -> anyhow::Result<()> {
if config.run.executor == RunExecutorKind::Vm
&& config.vm.rootfs.is_none()
Expand Down Expand Up @@ -4144,6 +4186,128 @@ mod tests {
);
}

#[test]
fn result_destination_checks_implicit_selected_storage() {
const ROOT: &str = "PVISOR_RESULT_STORAGE_TEST_ROOT";
const NAME: &str = "cli::run::tests::result_destination_checks_implicit_selected_storage";
if let Some(root) = std::env::var_os(ROOT) {
let root = PathBuf::from(root);
let config = RunConfig::default();
assert!(config.overlayfs.is_none());
let selected = select_run_storage(&config, &root.join("w"), "run-implicit").unwrap();
assert_eq!(selected, root.join("runs/run-implicit"));
let storage = resolve_run_storage(&selected).unwrap();
assert!(
validate_result_file_destination(&storage.join("run-bundle.json"), &storage)
.unwrap_err()
.to_string()
.contains("--result-file must be outside Session storage")
);
validate_result_file_destination(
&root.join("runs/run-implicit2/result.json"),
&storage,
)
.unwrap();
assert_eq!(std::fs::read_dir(&storage).unwrap().count(), 0);
std::fs::write(root.join("checked"), b"implicit storage validated").unwrap();
return;
}
// Isolate environment/cwd in one native child; do not mutate process-wide
// test state or consult the real user's HOME/Run Home.
let root = tempfile::Builder::new()
.prefix("result-storage-")
.tempdir()
.unwrap()
.keep()
.canonicalize()
.unwrap();
std::fs::create_dir(root.join("w")).unwrap();
std::fs::create_dir(root.join("h")).unwrap();
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", NAME, "--nocapture"])
.current_dir(&root)
.env(ROOT, &root)
.env("HOME", root.join("h"))
.env("XDG_CONFIG_HOME", root.join("h/config"))
.env("XDG_CACHE_HOME", root.join("h/cache"))
.env("XDG_STATE_HOME", root.join("h/state"))
.env("PVISOR_RUN_HOME", "runs")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(status) = child.try_wait().unwrap() {
assert!(status.success());
break;
}
if std::time::Instant::now() >= deadline {
child.kill().unwrap();
child.wait().unwrap();
panic!(
"implicit storage child exceeded its watchdog: {}",
root.display()
);
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(
std::fs::read(root.join("checked")).unwrap(),
b"implicit storage validated"
);
std::fs::remove_dir_all(root).unwrap();
}

#[cfg(unix)]
#[test]
fn result_destination_resolves_existing_ancestor_aliases_and_external_obstructions() {
let root = tempfile::Builder::new()
.prefix("result-alias-")
.tempdir()
.unwrap()
.keep()
.canonicalize()
.unwrap();
let storage = resolve_run_storage(&root.join("stage")).unwrap();
std::fs::create_dir(root.join("external")).unwrap();
std::os::unix::fs::symlink(&storage, root.join("inside-alias")).unwrap();
std::os::unix::fs::symlink(root.join("external"), root.join("outside-alias")).unwrap();
std::fs::write(storage.join("blocked"), b"inside sentinel").unwrap();
std::fs::write(root.join("external/blocked"), b"outside sentinel").unwrap();
for path in [
root.join("inside-alias/export/result.json"),
storage.join("blocked/result.json"),
storage.join("../stage/completion.json"),
] {
assert!(
validate_result_file_destination(&path, &storage)
.unwrap_err()
.to_string()
.contains("--result-file must be outside Session storage")
);
}
for path in [
root.join("outside-alias/export/result.json"),
root.join("outside-alias/blocked/result.json"),
storage.join("../stage2/run-bundle.json"),
] {
validate_result_file_destination(&path, &storage).unwrap();
}
assert_eq!(
std::fs::read(storage.join("blocked")).unwrap(),
b"inside sentinel"
);
assert_eq!(
std::fs::read(root.join("external/blocked")).unwrap(),
b"outside sentinel"
);
assert!(!root.join("external/export").exists());
assert!(!storage.join("export").exists());
assert!(!root.join("stage2").exists());
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn removed_interactive_flags_are_rejected() {
for flag in ["--tui", "--ask"] {
Expand Down
11 changes: 8 additions & 3 deletions crates/pvisor-cli/tests/export_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ fn clap_requires_destination_and_exclusive_explicit_selection_before_io() {
fn spawn_help_describes_static_policies_and_plain_stdio() {
let root = tempfile::tempdir().unwrap();
let text = help(root.path(), &["spawn", "--help"]);
for option in ["--safe", "--stdio", "PATH-GLOB:deny|warn"] {
for option in [
"--safe",
"--stdio",
"PATH-GLOB:deny|warn",
"outside Session storage",
] {
assert!(text.contains(option), "missing {option}: {text}");
}
for option in ["--tui", "--ask", "deny|ask|warn"] {
Expand All @@ -126,8 +131,8 @@ fn run_and_spawn_help_match_their_pinned_baselines() {
),
(
"spawn",
// Pin the Session lifecycle wording and caller-provided RAM pin service.
"d7d1221a53af117178edcb2837372e1cb9cacacea628982a8528fbd348b35009",
// Pin lifecycle/RAM wording and the outside-storage result export rule.
"be055fd50ddb3e41434bde7fd7b67d43fdb902f8744b4519f9d990e43ae2632c",
),
] {
let text = help(root.path(), &[command, "--help"]);
Expand Down
Loading
Loading