diff --git a/crates/challenge-agentic/src/tools.rs b/crates/challenge-agentic/src/tools.rs index 025e9e038..05c366133 100644 --- a/crates/challenge-agentic/src/tools.rs +++ b/crates/challenge-agentic/src/tools.rs @@ -453,7 +453,7 @@ fn tool_run_command(ctx: &ToolContext, args: &Value) -> Result RunFailure { } SandboxError::Docker(msg) => RunFailure::new(ErrorClass::Install, format!("docker: {msg}")), SandboxError::Io(e) => RunFailure::new(ErrorClass::AstInfra, format!("io: {e}")), - SandboxError::MissingOutput(m) => { - RunFailure::new(ErrorClass::Miner, format!("missing output: {m}")) + e @ (SandboxError::MissingOutput(_) | SandboxError::UnsafeOutput(_)) => { + RunFailure::new(ErrorClass::Miner, e.to_string()) } } } diff --git a/crates/design-challenge/src/screenshot.rs b/crates/design-challenge/src/screenshot.rs index 4a4585095..f244877d1 100644 --- a/crates/design-challenge/src/screenshot.rs +++ b/crates/design-challenge/src/screenshot.rs @@ -22,6 +22,7 @@ use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::Engine; +use design_sandbox::read_staged_bytes; use sha2::{Digest, Sha256}; use tracing::warn; @@ -192,7 +193,7 @@ fn capture_once( let _ = std::fs::remove_file(&png_path); return None; } - let bytes = std::fs::read(&png_path).ok(); + let bytes = read_staged_bytes(&png_path).ok(); let _ = std::fs::remove_file(&png_path); match bytes { Some(b) if !b.is_empty() && b.starts_with(b"\x89PNG") => Some(b), @@ -247,7 +248,8 @@ fn shoot( timeout, ); let _ = std::fs::remove_dir_all(&profile); - matches!(res, Some(o) if o.status.success() && out.is_file()) + matches!(res, Some(o) if o.status.success()) + && std::fs::symlink_metadata(out).is_ok_and(|m| !m.file_type().is_symlink() && m.is_file()) } /// Shared headless flags. `--no-sandbox`: the renderer sandbox needs userns / @@ -424,6 +426,20 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn read_staged_bytes_refuses_symlink() { + let dir = std::env::temp_dir().join(format!("shot-symlink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("secret.bin"); + std::fs::write(&target, b"LEAK").unwrap(); + let link = dir.join("shot.png"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(read_staged_bytes(&link).is_err()); + assert_eq!(read_staged_bytes(&target).unwrap(), b"LEAK"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn capture_falls_back_when_marker_missing() { let dir = std::env::temp_dir().join(format!("shot-nomarker-{}", std::process::id())); diff --git a/crates/design-sandbox/src/lib.rs b/crates/design-sandbox/src/lib.rs index 982e22374..153d5c587 100644 --- a/crates/design-sandbox/src/lib.rs +++ b/crates/design-sandbox/src/lib.rs @@ -7,6 +7,9 @@ use std::collections::HashMap; use std::fs; +use std::io::Read; +#[cfg(target_os = "linux")] +use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -22,6 +25,10 @@ pub const DEFAULT_RUNTIME_IMAGE: &str = "design-runtime:0.1.0"; /// Env-tunable on the service via `DESIGN_INSTALL_TIMEOUT_SECS`. pub const DEFAULT_INSTALL_TIMEOUT_SECS: u64 = 300; +/// Linux `O_NOFOLLOW` (`asm-generic/fcntl.h`) — open must not traverse a symlink. +#[cfg(target_os = "linux")] +const O_NOFOLLOW: i32 = 0x20000; + /// Sandbox errors. #[derive(Debug, Error)] pub enum SandboxError { @@ -44,6 +51,59 @@ pub enum SandboxError { /// Missing output. #[error("missing output: {0}")] MissingOutput(String), + /// Symlink or non-regular file under staging (refused; do not follow). + #[error("unsafe output: {0}")] + UnsafeOutput(String), +} + +/// Read a staging path without following symlinks (`O_NOFOLLOW` on Linux). +/// +/// Miner `out/pages/*` may be symlinks into the design-challenge mount NS +/// (`/run/base/*`, `/proc/1/environ`); collectors must not follow them (R15). +pub fn read_staged_bytes(path: &Path) -> Result, SandboxError> { + let meta = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(SandboxError::MissingOutput(path.display().to_string())); + } + Err(e) => return Err(SandboxError::Io(e)), + }; + let label = path.file_name().map_or_else( + || path.display().to_string(), + |s| s.to_string_lossy().into_owned(), + ); + if meta.file_type().is_symlink() { + return Err(SandboxError::UnsafeOutput(format!( + "{label}: symlink refused" + ))); + } + if !meta.file_type().is_file() { + return Err(SandboxError::UnsafeOutput(format!( + "{label}: not a regular file" + ))); + } + let mut file = fs::OpenOptions::new(); + file.read(true); + #[cfg(target_os = "linux")] + { + file.custom_flags(O_NOFOLLOW); + } + let mut f = file.open(path)?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + Ok(buf) +} + +/// UTF-8 staging read; same gates as [`read_staged_bytes`]. +pub fn read_staged_text(path: &Path) -> Result { + let bytes = read_staged_bytes(path)?; + String::from_utf8(bytes).map_err(|_| { + let label = path.file_name().map_or_else( + || path.display().to_string(), + |s| s.to_string_lossy().into_owned(), + ); + SandboxError::UnsafeOutput(format!("{label}: not utf-8")) + }) } /// Collected `/out` pages. @@ -221,15 +281,34 @@ impl DockerSandbox { let mut pages = HashMap::new(); for req in REQUIRED_PAGES { let p = work.join("out/pages").join(req); - if !p.is_file() { - return Err(SandboxError::MissingOutput((*req).into())); + match read_staged_text(&p) { + Ok(body) => { + pages.insert((*req).into(), body); + } + Err(SandboxError::MissingOutput(_)) => { + return Err(SandboxError::MissingOutput((*req).into())); + } + Err(SandboxError::UnsafeOutput(_)) => { + return Err(SandboxError::UnsafeOutput((*req).into())); + } + Err(e) => return Err(e), } - pages.insert((*req).into(), fs::read_to_string(p)?); } Ok(pages) } } +/// Optional `.miner_env.json` written at stage time (run-phase only). Refuse +/// symlink / non-regular replacements planted during install. +fn load_miner_env_file(work: &Path) -> Result, SandboxError> { + let path = work.join(".miner_env.json"); + match read_staged_text(&path) { + Ok(raw) => Ok(Some(raw)), + Err(SandboxError::MissingOutput(_)) => Ok(None), + Err(e) => Err(e), + } +} + impl SandboxBackend for DockerSandbox { fn install( &self, @@ -304,10 +383,8 @@ echo pip-install-ok" format!("HTTP_PROXY={egress_proxy}"), format!("HTTPS_PROXY={egress_proxy}"), ]; - // Re-read env from staged files is not possible; callers must put - // validated env on the bundle before install/run. We pass via a - // side channel file written at stage time when present. - if let Ok(raw) = fs::read_to_string(work.join(".miner_env.json")) { + // Side channel written at stage time; never follow miner-planted symlinks. + if let Some(raw) = load_miner_env_file(&work)? { if let Ok(map) = serde_json::from_str::>(&raw) { @@ -434,7 +511,7 @@ impl SandboxBackend for SimSandbox { .env("DESIGN_RUN_ID", run_id) .env("DESIGN_ROUND_ID", round_id.to_string()) .env("DESIGN_PROMPT", prompt); - if let Ok(raw) = fs::read_to_string(work.join(".miner_env.json")) { + if let Some(raw) = load_miner_env_file(&work)? { if let Ok(map) = serde_json::from_str::>(&raw) { @@ -539,12 +616,67 @@ mod tests { use super::*; use design_harness::HarnessBundle; use std::collections::BTreeMap; + use std::os::unix::fs::symlink; + use tempfile::tempdir; const BASELINE_AGENT: &str = include_str!("../../../docs/external-miner/examples/design-baseline/agent.py"); const BASELINE_PYPROJECT: &str = include_str!("../../../docs/external-miner/examples/design-baseline/pyproject.toml"); + #[test] + fn collect_out_rejects_symlink_pages() { + let dir = tempdir().unwrap(); + let pages = dir.path().join("out/pages"); + fs::create_dir_all(&pages).unwrap(); + // Secret outside staging (simulates /run/base/... in the challenge NS). + let secret = dir.path().join("secret_outside"); + fs::write(&secret, "LEAKED_CHALLENGE_SK\n").unwrap(); + for req in REQUIRED_PAGES { + if *req == "index.html" { + symlink(&secret, pages.join(req)).unwrap(); + } else { + fs::write(pages.join(req), format!("{req}")).unwrap(); + } + } + let err = DockerSandbox::collect_out(dir.path()).unwrap_err(); + let msg = err.to_string(); + match err { + SandboxError::UnsafeOutput(name) => assert_eq!(name, "index.html"), + other => panic!("expected UnsafeOutput, got {other:?}"), + } + // Must not have followed the link (content never enters artifacts / errors). + assert!(!msg.contains("LEAKED"), "error must not echo secret body"); + } + + #[test] + fn read_staged_text_rejects_symlink_and_accepts_regular() { + let dir = tempdir().unwrap(); + let regular = dir.path().join("ok.html"); + fs::write(®ular, "ok").unwrap(); + assert_eq!(read_staged_text(®ular).unwrap(), "ok"); + + let target = dir.path().join("target"); + fs::write(&target, "SECRET").unwrap(); + let link = dir.path().join("evil.html"); + symlink(&target, &link).unwrap(); + let err = read_staged_text(&link).unwrap_err(); + assert!( + matches!(err, SandboxError::UnsafeOutput(ref m) if m.contains("symlink")), + "{err:?}" + ); + } + + #[test] + fn load_miner_env_refuses_symlink() { + let dir = tempdir().unwrap(); + let target = dir.path().join("sk"); + fs::write(&target, r#"{"X":"1"}"#).unwrap(); + symlink(&target, dir.path().join(".miner_env.json")).unwrap(); + let err = load_miner_env_file(dir.path()).unwrap_err(); + assert!(matches!(err, SandboxError::UnsafeOutput(_)), "{err:?}"); + } + #[test] fn install_env_carries_no_miner_secrets() { let env = DockerSandbox::install_env("http://proxy:1"); diff --git a/docs/DESIGN_CHALLENGE.md b/docs/DESIGN_CHALLENGE.md index 7af125cfa..7185a0583 100644 --- a/docs/DESIGN_CHALLENGE.md +++ b/docs/DESIGN_CHALLENGE.md @@ -185,6 +185,11 @@ DNS resolution** (DNS-rebinding safe). - `User: 65532:65532` - Wall-clock timeout → stop/rm +After the sandbox exits, host-side collection of `out/pages/*` (and other +staging reads) **refuses symlinks and non-regular files** (`O_NOFOLLOW` on +Linux). A harness must not exfiltrate design-challenge secret mounts into +stored artifacts by replacing required HTML with links (threat model R15). + Host `SimSandbox` is fail-closed outside explicit non-prod/CI opt-in (`BASE_ALLOW_HOST_SIM=1` + non-prod, typically via `env-local.yml` or e2e). Staging/prod paths are Docker-only via `socket-proxy` — no silent fallback. diff --git a/docs/OPERATOR_SECURITY.md b/docs/OPERATOR_SECURITY.md index eec7db5a4..b67e52c08 100644 --- a/docs/OPERATOR_SECURITY.md +++ b/docs/OPERATOR_SECURITY.md @@ -13,7 +13,7 @@ Use this before every promote and after every incident. Architecture: [`ARCHITEC - [ ] Challenge signing secrets are **files** mounted into the challenge service, not env values (D11). - [ ] Owner and challenge mini-secrets never committed; only `*.pubkey` / TOML bodies + detached `.sig` in git. - [ ] Cloudflare / DO / Phala tokens live only in operator secret stores, not in docs or CI logs. -- [ ] Design agentic review: OpenRouter key is mounted on `design-challenge` / `design-egress-proxy` as a **file**, never into miner sandboxes. The ephemeral `design-review` container must receive the key via a **file mount** (`OPENROUTER_API_KEY_FILE`), never as `OPENROUTER_API_KEY` in container env (`/proc//environ` is boot-fixed). `run_command` must keep procfs and `/run/review-secrets` denied. Do not turn off `AGENTIC_ENABLE_RUN_COMMAND` in prod without a replacement inspection path. +- [ ] Design agentic review: OpenRouter key is mounted on `design-challenge` / `design-egress-proxy` as a **file**, never into miner sandboxes. The ephemeral `design-review` container must receive the key via a **file mount** (`OPENROUTER_API_KEY_FILE`), never as `OPENROUTER_API_KEY` in container env (`/proc//environ` is boot-fixed). `run_command` must keep procfs, `/run/review-secrets`, and `/run/base` secret paths denied. Staging collect must refuse symlinks (R15). Do not turn off `AGENTIC_ENABLE_RUN_COMMAND` in prod without a replacement inspection path. --- diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 1c4d47043..3381e8f59 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -107,6 +107,7 @@ A malicious owner can authorize a dishonest challenge or a backdoored measuremen | Malicious owner | In scope of D19(ii) / R12 — not eliminated | | Colluding validator set deleting evidence | D19(iv) / D5 — no public anchor | | Malicious miner HTML/JS (stored XSS on the site origin via `/v1/view`) | Blocked by R13 layering; any single layer suffices | +| Malicious harness planting staging symlinks to challenge secret mounts | Blocked by R15 (no follow on collect / staging reads) | --- @@ -119,6 +120,7 @@ A malicious owner can authorize a dishonest challenge or a backdoored measuremen | R4 | Zero emission possible | Extrinsic success + revealed weights match recompute is pass; emission is not | | R13 | Miner-generated design pages XSS-ing the joinbase.ai origin (cookie/session theft, phishing) when viewed | Four independent layers, each sufficient alone: (1) ammonia sanitize strips `