From fb8e447b97336fa9d0beeac29c0204d07a6fef6e Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:19:37 +0000 Subject: [PATCH 1/2] fix(design): refuse staging symlinks that escape into challenge secrets Miner-controlled out/pages (and other staging paths) could be replaced with symlinks into the design-challenge mount NS. Collect/read with symlink_metadata + O_NOFOLLOW, harden review run_command denylist, and document as R15. --- crates/challenge-agentic/src/tools.rs | 98 +++++++++++-- crates/design-challenge/src/orchestrator.rs | 3 + crates/design-challenge/src/screenshot.rs | 51 ++++++- crates/design-sandbox/src/lib.rs | 150 ++++++++++++++++++-- docs/DESIGN_CHALLENGE.md | 5 + docs/OPERATOR_SECURITY.md | 2 +- docs/THREAT_MODEL.md | 2 + 7 files changed, 288 insertions(+), 23 deletions(-) diff --git a/crates/challenge-agentic/src/tools.rs b/crates/challenge-agentic/src/tools.rs index 025e9e038..c820484db 100644 --- a/crates/challenge-agentic/src/tools.rs +++ b/crates/challenge-agentic/src/tools.rs @@ -2,8 +2,13 @@ use std::fmt::Write as _; use std::fs; +use std::io::Read; use std::path::{Component, Path, PathBuf}; +/// Linux `O_NOFOLLOW` — workdir tool reads must not traverse symlinks. +#[cfg(target_os = "linux")] +const O_NOFOLLOW: i32 = 0x20000; + use challenge_ast::{ fingerprint_source, structural_diff_summary, summarize_fingerprint, top_k_nearest, Fingerprint, AST_CHEAT_BPS, AST_SUSPICIOUS_BPS, @@ -303,7 +308,11 @@ fn grep_walk( if hits.len() >= max_hits { return Ok(()); } - let meta = fs::metadata(path).map_err(|e| AgenticError::Tool(format!("grep: {e}")))?; + // lstat: never follow symlinks into the challenge mount NS. + let meta = fs::symlink_metadata(path).map_err(|e| AgenticError::Tool(format!("grep: {e}")))?; + if meta.file_type().is_symlink() { + return Ok(()); + } if meta.is_dir() { for ent in fs::read_dir(path) .map_err(|e| AgenticError::Tool(format!("grep: {e}")))? @@ -316,10 +325,10 @@ fn grep_walk( } return Ok(()); } - if meta.len() > 512 * 1024 { + if !meta.is_file() || meta.len() > 512 * 1024 { return Ok(()); } - let Ok(text) = fs::read_to_string(path) else { + let Ok(text) = read_workdir_text(path) else { return Ok(()); }; let rel = path @@ -344,7 +353,11 @@ fn tool_stat(ctx: &ToolContext, args: &Value) -> Result { .and_then(Value::as_str) .ok_or_else(|| AgenticError::Tool("file_stat: path required".into()))?; let path = resolve_rel(&ctx.workdir, rel)?; - let meta = fs::metadata(&path).map_err(|e| AgenticError::Tool(format!("file_stat: {e}")))?; + let meta = + fs::symlink_metadata(&path).map_err(|e| AgenticError::Tool(format!("file_stat: {e}")))?; + if meta.file_type().is_symlink() { + return Err(AgenticError::Tool("file_stat: symlink refused".into())); + } Ok(format!( "path={rel} is_dir={} len={}", meta.is_dir(), @@ -392,8 +405,7 @@ fn tool_read_metrics(ctx: &ToolContext) -> Result { return Ok("(no metrics configured)".into()); }; let path = resolve_rel(&ctx.workdir, rel)?; - let text = - fs::read_to_string(&path).map_err(|e| AgenticError::Tool(format!("metrics: {e}")))?; + let text = read_workdir_text(&path).map_err(|e| AgenticError::Tool(format!("metrics: {e}")))?; Ok(truncate(&text, 24_576)) } @@ -401,12 +413,21 @@ fn tool_read_pages(ctx: &ToolContext) -> Result { let mut out = String::new(); if let Some(rel) = &ctx.pages_relpath { let path = resolve_rel(&ctx.workdir, rel)?; - if path.is_dir() { + let meta = + fs::symlink_metadata(&path).map_err(|e| AgenticError::Tool(format!("pages: {e}")))?; + if meta.file_type().is_symlink() { + return Err(AgenticError::Tool("pages: symlink refused".into())); + } + if meta.is_dir() { let mut names = Vec::new(); for ent in fs::read_dir(&path) .map_err(|e| AgenticError::Tool(format!("pages: {e}")))? .flatten() { + // Skip dangling / escape symlinks in the listing. + if ent.file_type().map_or(true, |t| t.is_symlink()) { + continue; + } names.push(ent.file_name().to_string_lossy().into_owned()); } names.sort(); @@ -421,7 +442,7 @@ fn tool_read_pages(ctx: &ToolContext) -> Result { if let Some(rel) = &ctx.sanitize_report_relpath { out.push_str("\n--- sanitize_report ---\n"); match resolve_rel(&ctx.workdir, rel).and_then(|p| { - fs::read_to_string(p).map_err(|e| AgenticError::Tool(format!("sanitize: {e}"))) + read_workdir_text(&p).map_err(|e| AgenticError::Tool(format!("sanitize: {e}"))) }) { Ok(t) => out.push_str(&truncate(&t, 16_384)), Err(e) => out.push_str(&e.to_string()), @@ -451,9 +472,10 @@ fn tool_run_command(ctx: &ToolContext, args: &Value) -> Result 1_000 || cmd.contains('\0') { return Err(AgenticError::Tool("run_command: bad command".into())); } - // File-mounted OpenRouter key + parent environ must stay unread. + // File-mounted secrets + parent environ must stay unread (defense-in-depth; + // primary staging collectors also refuse symlinks — see design-sandbox). let c = cmd.to_ascii_lowercase().replace('\\', "/"); - if c.contains("/proc") || c.contains("review-secrets") || c.contains("openrouter_api_key") { + if run_command_forbidden_path(&c) { return Err(AgenticError::Tool("run_command: forbidden path".into())); } let rel = args.get("path").and_then(Value::as_str).unwrap_or("."); @@ -514,7 +536,45 @@ fn read_py(ctx: &ToolContext, args: &Value) -> Result { .and_then(Value::as_str) .ok_or_else(|| AgenticError::Tool("path required".into()))?; let path = resolve_rel(&ctx.workdir, rel)?; - fs::read_to_string(&path).map_err(|e| AgenticError::Tool(format!("read py: {e}"))) + read_workdir_text(&path).map_err(|e| AgenticError::Tool(format!("read py: {e}"))) +} + +/// Substrings refused in `run_command` (lowercase, `/`-normalized). +fn run_command_forbidden_path(c: &str) -> bool { + const NEEDLES: &[&str] = &[ + "/proc", + "review-secrets", + "openrouter_api_key", + "/run/base", + "challenge_sk", + "design_sk", + "annotator_tokens", + "gateway_admin", + "gateway_sk", + ]; + NEEDLES.iter().any(|n| c.contains(n)) +} + +/// Read a workdir file without following symlinks (`O_NOFOLLOW` on Linux). +fn read_workdir_text(path: &Path) -> Result { + let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if meta.file_type().is_symlink() { + return Err("symlink refused".into()); + } + if !meta.file_type().is_file() { + return Err("not a regular file".into()); + } + let mut opts = fs::OpenOptions::new(); + opts.read(true); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::OpenOptionsExt; + opts.custom_flags(O_NOFOLLOW); + } + let mut f = opts.open(path).map_err(|e| e.to_string())?; + let mut buf = String::new(); + f.read_to_string(&mut buf).map_err(|e| e.to_string())?; + Ok(buf) } /// Resolve `rel` under `workdir`; reject `..` / absolute / symlink escapes. @@ -678,7 +738,7 @@ pub(crate) fn load_primary_sources( let mut out = Vec::new(); for rel in &req.primary_relpaths { let path = resolve_rel(&workdir, rel)?; - let text = fs::read_to_string(&path) + let text = read_workdir_text(&path) .map_err(|e| AgenticError::Tool(format!("primary {rel}: {e}")))?; out.push((rel.clone(), text)); } @@ -744,6 +804,9 @@ 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/design/annotator_tokens", + "cat /run/base/openrouter/api_key", ] { let err = tool_run_command(&ctx, &json!({"command": bad})) .unwrap_err() @@ -752,6 +815,17 @@ mod tests { } } + #[test] + fn read_workdir_text_refuses_symlink() { + let dir = tempdir().unwrap(); + let target = dir.path().join("secret"); + fs::write(&target, "LEAK").unwrap(); + let link = dir.path().join("agent.py"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let err = read_workdir_text(&link).unwrap_err(); + assert!(err.contains("symlink"), "{err}"); + } + #[test] fn run_command_disabled_without_container_env() { let _guard = RUN_COMMAND_ENV_LOCK diff --git a/crates/design-challenge/src/orchestrator.rs b/crates/design-challenge/src/orchestrator.rs index c7d11f38f..4ab162b56 100644 --- a/crates/design-challenge/src/orchestrator.rs +++ b/crates/design-challenge/src/orchestrator.rs @@ -111,6 +111,9 @@ fn classify_sandbox(e: &SandboxError) -> RunFailure { SandboxError::MissingOutput(m) => { RunFailure::new(ErrorClass::Miner, format!("missing output: {m}")) } + SandboxError::UnsafeOutput(m) => { + RunFailure::new(ErrorClass::Miner, format!("unsafe output: {m}")) + } } } diff --git a/crates/design-challenge/src/screenshot.rs b/crates/design-challenge/src/screenshot.rs index 4a4585095..941a7532b 100644 --- a/crates/design-challenge/src/screenshot.rs +++ b/crates/design-challenge/src/screenshot.rs @@ -17,6 +17,7 @@ //! unintended scripts and navigations (CLI Chromium has no Playwright route //! hooks). +use std::io::Read; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -25,6 +26,30 @@ use base64::Engine; use sha2::{Digest, Sha256}; use tracing::warn; +/// Linux `O_NOFOLLOW` — screenshot staging reads must not follow symlinks. +#[cfg(target_os = "linux")] +const O_NOFOLLOW: i32 = 0x20000; + +/// Read bytes from a screenshot staging path without following symlinks. +fn read_staging_bytes(path: &Path) -> Option> { + let meta = std::fs::symlink_metadata(path).ok()?; + if meta.file_type().is_symlink() || !meta.file_type().is_file() { + warn!(path = %path.display(), "screenshot staging: refused non-regular file"); + return None; + } + let mut opts = std::fs::OpenOptions::new(); + opts.read(true); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::OpenOptionsExt; + opts.custom_flags(O_NOFOLLOW); + } + let mut f = opts.open(path).ok()?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf).ok()?; + Some(buf) +} + /// Capture viewport width in px (matches the site preview column). const WIDTH: u32 = 1280; /// Floor for the measured page height in px. @@ -192,7 +217,7 @@ fn capture_once( let _ = std::fs::remove_file(&png_path); return None; } - let bytes = std::fs::read(&png_path).ok(); + let bytes = read_staging_bytes(&png_path); let _ = std::fs::remove_file(&png_path); match bytes { Some(b) if !b.is_empty() && b.starts_with(b"\x89PNG") => Some(b), @@ -247,7 +272,12 @@ fn shoot( timeout, ); let _ = std::fs::remove_dir_all(&profile); - matches!(res, Some(o) if o.status.success() && out.is_file()) + let ok = matches!(res, Some(o) if o.status.success()); + if !ok { + return false; + } + // Refuse symlink / non-regular outputs (defense-in-depth vs staging escape). + 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 +454,23 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn read_staging_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_staging_bytes(&link).is_none()); + assert_eq!( + read_staging_bytes(&target).as_deref(), + Some(b"LEAK".as_slice()) + ); + 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..ac4d76d5c 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,61 @@ 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 miner-staging path without following symlinks. +/// +/// After the sandbox exits, `out/pages/*` is attacker-controlled. A harness can +/// replace required HTML with symlinks into the **design-challenge** mount NS +/// (`/run/base/challenge_sk`, OpenRouter key, annotator tokens, `/proc/1/environ`). +/// Collectors must refuse symlinks / non-regular files and prefer `O_NOFOLLOW`. +pub(crate) 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 symlink / regular-file gates as [`read_staged_bytes`]. +pub(crate) 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 +283,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 +385,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 +513,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 +618,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 `