Skip to content
Open
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: 3 additions & 1 deletion crates/challenge-agentic/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ fn tool_run_command(ctx: &ToolContext, args: &Value) -> Result<String, AgenticEr
}
// File-mounted OpenRouter key + parent environ must stay unread.
let c = cmd.to_ascii_lowercase().replace('\\', "/");
if c.contains("/proc") || c.contains("review-secrets") || c.contains("openrouter_api_key") {
if c.contains("/proc") || c.contains("review-secrets") || c.contains("/run/base") {
return Err(AgenticError::Tool("run_command: forbidden path".into()));
}
let rel = args.get("path").and_then(Value::as_str).unwrap_or(".");
Expand Down Expand Up @@ -744,6 +744,8 @@ mod tests {
"cat /proc/1/environ",
"python -c 'open(\"//proc/self/environ\").read()'",
"cat /run/review-secrets/openrouter_api_key",
"cat /run/base/challenge_sk",
"cat /run/base/openrouter/api_key",
] {
let err = tool_run_command(&ctx, &json!({"command": bad}))
.unwrap_err()
Expand Down
4 changes: 2 additions & 2 deletions crates/design-challenge/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ fn classify_sandbox(e: &SandboxError) -> 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())
}
}
}
Expand Down
20 changes: 18 additions & 2 deletions crates/design-challenge/src/screenshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 /
Expand Down Expand Up @@ -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()));
Expand Down
148 changes: 140 additions & 8 deletions crates/design-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand All @@ -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<Vec<u8>, 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<String, SandboxError> {
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.
Expand Down Expand Up @@ -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<Option<String>, 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,
Expand Down Expand Up @@ -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::<std::collections::BTreeMap<String, String>>(&raw)
{
Expand Down Expand Up @@ -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::<std::collections::BTreeMap<String, String>>(&raw)
{
Expand Down Expand Up @@ -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!("<html>{req}</html>")).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(&regular, "<html>ok</html>").unwrap();
assert_eq!(read_staged_text(&regular).unwrap(), "<html>ok</html>");

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");
Expand Down
5 changes: 5 additions & 0 deletions docs/DESIGN_CHALLENGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/OPERATOR_SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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/<pid>/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.

---

Expand Down
2 changes: 2 additions & 0 deletions docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

---

Expand All @@ -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 `<script>`/handlers before storage; (2) response CSP `sandbox` with **no** `allow-scripts`/`allow-same-origin` → opaque origin, scripts disabled, no cookie/storage access, `frame-ancestors` allowlist, never `Set-Cookie`; (3) gateway proxy re-applies the header floor and strips `Set-Cookie` on `/challenge/*/v1/view/*` (survives stale upstreams); (4) frontend embeds with `<iframe sandbox="">`. Browser-tested: injected `<script>` stays inert under each layer independently. Produced HTML is never served (screenshots-only). |
| R14 | Screenshot Chromium inside design-challenge (`--no-sandbox`, `file://`) SSRF-ing control-plane targets on the `base` network (gateway admin, metadata `169.254.169.254`, socket-proxy, postgres) via missed script or static `http(s)` / CSS `url(...)` | Defense-in-depth: (1) sanitize neutralizes internal `href`/`src`; (2) Chromium forced through `design-egress-proxy` (`DESIGN_SCREENSHOT_PROXY` + `--proxy-bypass-list=<-loopback>`) with the same post-DNS blocklist as sandboxes; (3) capture-document CSP nonce + `navigate-to 'none'`. Host Sim (`BASE_ALLOW_HOST_SIM`) remains fail-closed on staging/prod. |
| R15 | Miner replaces sandbox `out/pages/*.html` (or other staging paths) with symlinks into the **design-challenge** mount namespace (`/run/base/challenge_sk`, OpenRouter key, annotator tokens, `/proc/1/environ`) so host-side collect/read follows them into artifacts | Staging collectors refuse symlinks / non-regular files (`symlink_metadata` + Linux `O_NOFOLLOW`); miner fault (`unsafe output`). Review `run_command` also denies `/run/base` and secret path needles. Does **not** cover validator/gateway hotkeys (not mounted on design-challenge). |

---

Expand Down
Loading