#!/bin/bash
# PRUVA R3B — CVE-2026-70608 escalation probe against the REAL hermes desktop app.
#
# Question (bundle/ticket.md): the right-rail URL preview webview
# (apps/desktop/src/app/chat/right-rail/preview-pane.tsx:557-561) creates a
# <webview partition="persist:hermes-preview"
#         webpreferences="contextIsolation=yes,nodeIntegration=no,sandbox=yes">
# whose guest webContents has NO setWindowOpenHandler (Electron default = ALLOW).
# If the CVE-2026-70608 OpenURL bypass (sandboxed iframe without allow-popups
# dispatching a synthetic ctrl/meta-click) fires inside that guest, does a REAL
# application window spawn (escalation past R2's capped external-open), or does
# the open hit the external path, or is it blocked? Control: Electron 41.10.3.
#
# Verified answer produced by this script (see rca_report.md):
#   Electron 40.10.2: the sandboxed iframe's synthetic ctrl+click spawns a REAL
#     BrowserWindow loading the attacker URL (guest 'did-create-window' +
#     'browser-window-created' + did-finish-load of the marker URL) — outcome (a).
#   Electron 41.10.3: the same iframe trigger is blocked (no window) — control OK.
#
# Environment constraints this script is built around (measured in the eval
# container): 1.5GB memory cgroup (npm/rolldown OOM), no user namespaces
# (Chromium OS sandbox cannot start -> --no-sandbox, same as the product's own
# e2e fixtures; the iframe popup-sandbox under test is a Blink feature and is
# unaffected), background tasks capped (~600s) -> long servers run detached.
#
# Renderer build: `vite build`/`vite dev` both OOM in the cgroup, so the SAME
# renderer sources are bundled with esbuild (scripts/pruva-bundle-renderer.mjs,
# copied from bundle/repro/bundle_renderer.mjs) and served statically; the
# electron main process is bundled with the product's own
# scripts/bundle-electron-main.mjs --dev and loads the renderer via
# HERMES_DESKTOP_DEV_SERVER — the product's own dev-mode entry.
#
# Exit 0 = vulnerable runs show the CVE bypass spawning real windows AND fixed
#          runs block it. Exit 1 otherwise.

set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
mkdir -p "$LOGS" "$REPRO_DIR"
LOGFILE="$LOGS/reproduction_steps.log"
exec > >(tee -a "$LOGFILE") 2>&1

echo "=== pruva r3b reproduction $(date -u +%FT%TZ) ==="

REPO_URL="https://github.com/NousResearch/hermes-agent"
TICKET_COMMIT="e3fab0437ee50ebe511cec57b9ac36f0c2803268"
VULN_ELECTRON="40.10.2"
FIXED_ELECTRON="41.10.3"

# ── work layout: build on overlay (/tmp), NOT on the tmpfs project cache.
# tmpfs pages are charged to this container's 1.5GB memory cgroup and are not
# reclaimable fast enough; pnpm/esbuild get OOM-killed otherwise.
WORK="${PRUVA_WORK_DIR:-/tmp/pruva-r3b-work}"
REPO="$WORK/repo"
DESKTOP="$REPO/apps/desktop"
export PRUVA_REPO="$REPO"

CACHE_CTX="$ROOT/project_cache_context.json"
CACHE_REPO=""
CACHE_MIRROR=""
if [ -f "$CACHE_CTX" ]; then
  CACHE_REPO="$(jq -r 'select(.prepared==true) | .project_cache_dir + "/repo"' "$CACHE_CTX" 2>/dev/null || true)"
  CACHE_MIRROR="$(jq -r 'select(.prepared==true) | .repo_mirror_dir + "/hermes-agent.git"' "$CACHE_CTX" 2>/dev/null || true)"
fi

mkdir -p "$WORK"
if [ ! -d "$REPO/.git" ]; then
  if [ -n "$CACHE_REPO" ] && [ -d "$CACHE_REPO/.git" ]; then
    echo "[*] seeding work repo from prepared project cache: $CACHE_REPO"
    git clone --quiet --local "$CACHE_REPO" "$REPO" || cp -a "$CACHE_REPO" "$REPO"
  elif [ -n "$CACHE_MIRROR" ] && [ -d "$CACHE_MIRROR" ]; then
    echo "[*] seeding work repo from cache mirror: $CACHE_MIRROR"
    git clone --quiet --local "$CACHE_MIRROR" "$REPO"
  else
    echo "[*] cloning $REPO_URL"
    git clone --quiet "$REPO_URL" "$REPO"
  fi
fi
git -C "$REPO" fetch --quiet origin "$TICKET_COMMIT" 2>/dev/null || true
# Keep the work tree clean of any previous run's generated files, then pin.
git -C "$REPO" checkout --quiet -- . 2>/dev/null || true
git -C "$REPO" checkout --quiet "$TICKET_COMMIT"
echo "[*] repo at $(git -C "$REPO" rev-parse HEAD)"

# ── system deps ──────────────────────────────────────────────────────────
if ! command -v xvfb-run >/dev/null 2>&1; then
  sudo apt-get update -qq
  sudo apt-get install -y -qq xvfb libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 \
    libcups2t64 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \
    libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2 libgtk-3-0t64
fi
command -v pnpm >/dev/null 2>&1 || sudo npm install -g pnpm@10

# ── node deps via pnpm (npm OOMs on this monorepo under the cgroup) ──────
if [ ! -f "$DESKTOP/node_modules/electron/dist/electron" ]; then
  if [ ! -f "$REPO/pnpm-workspace.yaml" ]; then
    cat > "$REPO/pnpm-workspace.yaml" <<'YAML'
packages:
  - 'apps/*'
  - 'ui-tui'
  - 'ui-tui/packages/*'
  - 'web'
  - 'tests-js'
onlyBuiltDependencies:
  - electron
  - electron-winstaller
  - esbuild
  - get-windows
  - node-pty
  - unicode-animations
YAML
  fi
  if [ ! -f "$REPO/pnpm-lock.yaml" ]; then
    (cd "$REPO" && pnpm import)
  fi
  (cd "$REPO" && CI=true pnpm install --frozen-lockfile --reporter=append-only) \
    || (cd "$REPO" && CI=true pnpm install --reporter=append-only)
fi
# fixtures.findElectron() looks at <root>/node_modules/electron
ln -sfn ../apps/desktop/node_modules/electron "$REPO/node_modules/electron"
ELECTRON_DIST="$DESKTOP/node_modules/electron/dist"
sudo chown root:root "$ELECTRON_DIST/chrome-sandbox" 2>/dev/null || true
sudo chmod 4755 "$ELECTRON_DIST/chrome-sandbox" 2>/dev/null || true
echo "[*] electron $(node -p "require('$DESKTOP/node_modules/electron/package.json').version") at $ELECTRON_DIST"

# ── python backend venv (desktop spawns `<root>/venv/bin/python -m hermes_cli.main serve`) ──
if [ ! -x "$REPO/venv/bin/python" ]; then
  python3 -m venv "$REPO/venv"
  "$REPO/venv/bin/pip" install -q --upgrade pip
  # repo metadata pins python <3.14; the code runs fine on 3.14.
  "$REPO/venv/bin/pip" install -q --ignore-requires-python -e "$REPO"
fi
(cd "$REPO" && "$REPO/venv/bin/python" -c "import hermes_cli.main" 2>/dev/null) \
  && echo "[*] backend import ok"

# ── observation-only main-process instrumentation ────────────────────────
python3 "$REPRO_DIR/patch_instrumentation.py" "$DESKTOP/electron/main.ts"

# ── bundle electron main (product's own bundler, dev mode) ───────────────
if [ ! -f "$DESKTOP/dist/electron-main.mjs" ] || ! grep -q PRUVA-REPRO-INSTRUMENTATION "$DESKTOP/dist/electron-main.mjs"; then
  (cd "$DESKTOP" && node scripts/write-build-stamp.mjs && node scripts/bundle-electron-main.mjs --dev)
fi

# ── bundle the renderer with esbuild (vite OOMs in this cgroup) ──────────
cp "$REPRO_DIR/bundle_renderer.mjs" "$DESKTOP/scripts/pruva-bundle-renderer.mjs"
if [ ! -f "$DESKTOP/dist-dev/main.js" ]; then
  # Force page-cache reclaim so esbuild's ~1.1GB peak fits the 1.5GB cgroup.
  python3 -c "x=bytearray(700*1024*1024); [x.__setitem__(i,1) for i in range(0,len(x),1048576)]; del x" || true
  (cd "$DESKTOP" && GOGC=20 node scripts/pruva-bundle-renderer.mjs)
fi
ls -la "$DESKTOP/dist/electron-main.mjs" "$DESKTOP/dist-dev/index.html" "$DESKTOP/dist-dev/main.js"

# ── fixed-control electron (41.10.3), standalone ─────────────────────────
FIXED_ELECTRON_BIN="$WORK/electron-fixed/node_modules/electron/dist/electron"
if [ ! -x "$FIXED_ELECTRON_BIN" ]; then
  mkdir -p "$WORK/electron-fixed"
  (cd "$WORK/electron-fixed" && npm init -y >/dev/null 2>&1; \
   npm install --no-audit --no-fund "electron@$FIXED_ELECTRON" && \
   ([ -f node_modules/electron/dist/electron ] || node node_modules/electron/install.js))
fi
FIXED_ELECTRON_BIN="$WORK/electron-fixed/node_modules/electron/dist/electron"
sudo chown root:root "$(dirname "$FIXED_ELECTRON_BIN")/chrome-sandbox" 2>/dev/null || true
sudo chmod 4755 "$(dirname "$FIXED_ELECTRON_BIN")/chrome-sandbox" 2>/dev/null || true
echo "[*] fixed electron: $("$FIXED_ELECTRON_BIN" --version 2>/dev/null || echo unknown)"

# ── static renderer server (detached; background tasks are ~600s-capped) ──
cp "$REPRO_DIR/serve_static.mjs" "$WORK/serve_static.mjs"
if ! curl -s -o /dev/null --max-time 2 http://127.0.0.1:5174/main.js; then
  (setsid nohup node "$WORK/serve_static.mjs" "$DESKTOP/dist-dev" 5174 > "$WORK/static-server.log" 2>&1 </dev/null &)
  for i in $(seq 1 30); do
    curl -s -o /dev/null --max-time 2 http://127.0.0.1:5174/main.js && break
    sleep 1
  done
fi
echo "[*] renderer static server: $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/main.js)"

# ── install the driver spec ──────────────────────────────────────────────
cp "$REPRO_DIR/repro.spec.ts" "$DESKTOP/e2e/zz-pruva-r3b.spec.ts"

run_attempt() {
  local role="$1" attempt="$2" electron_bin="${3:-}"
  echo "[*] running $role attempt $attempt $(date -u +%FT%TZ)"
  # clean leftovers from any previous attempt
  pkill -f "hermes_cli.main" 2>/dev/null || true
  pkill -f "electron.*apps/desktop" 2>/dev/null || true
  sleep 1
  (cd "$DESKTOP" && \
    PRUVA_ROLE="$role" PRUVA_ATTEMPT="$attempt" PRUVA_OUT_DIR="$LOGS" \
    PRUVA_ELECTRON_BIN="$electron_bin" \
    HERMES_DESKTOP_DEV_SERVER="http://127.0.0.1:5174" \
    timeout 560 xvfb-run -a --server-args="-screen 0 1280x900x24" \
      npx playwright test e2e/zz-pruva-r3b.spec.ts --reporter=list --workers=1) \
    > "$LOGS/${role}_attempt_${attempt}.log" 2>&1 || true
  grep -E "PRUVA-RESULT|passed|failed" "$LOGS/${role}_attempt_${attempt}.log" | tail -3 || true
}

# ── vulnerable runs (electron 40.10.2, pinned by the repo) ───────────────
run_attempt vulnerable 1
run_attempt vulnerable 2

# ── fixed control runs (electron 41.10.3) ────────────────────────────────
run_attempt fixed 1 "$FIXED_ELECTRON_BIN"
run_attempt fixed 2 "$FIXED_ELECTRON_BIN"

pkill -f "hermes_cli.main" 2>/dev/null || true

# ── classify + manifest ──────────────────────────────────────────────────
python3 - "$LOGS" "$REPRO_DIR" <<'PYEOF'
import json, sys, os, hashlib

logs, repro = sys.argv[1], sys.argv[2]

def load(role, n):
    p = os.path.join(logs, f"{role}_attempt_{n}.json")
    if not os.path.exists(p):
        p = os.path.join(logs, f"{role}_attempt_{n}.partial.json")
    if not os.path.exists(p):
        return None
    with open(p) as fh:
        return json.load(fh)

attempts = {}
for role in ("vulnerable", "fixed"):
    for n in (1, 2):
        attempts[f"{role}_{n}"] = load(role, n)

def outcome(role, n):
    a = attempts.get(f"{role}_{n}") or {}
    return a.get("outcome", "MISSING")

vuln = [outcome("vulnerable", 1), outcome("vulnerable", 2)]
fixed = [outcome("fixed", 1), outcome("fixed", 2)]
vuln_reached = all((attempts[f"vulnerable_{n}"] or {}).get("webviewFound") for n in (1, 2))
fixed_reached = all((attempts[f"fixed_{n}"] or {}).get("webviewFound") for n in (1, 2))

# The claim is proven when both vulnerable attempts spawn a REAL window from
# the CVE bypass (sandboxed iframe, no allow-popups) and both fixed attempts
# block that same trigger (reaching the guest but producing no such window).
vuln_cve = all(o == "A_REAL_WINDOW_SPAWNED_CVE_BYPASS" for o in vuln)
fixed_blocked = all(
    o in ("C_BLOCKED_NO_EFFECT", "A_REAL_WINDOW_SPAWNED_GENERIC_ONLY") for o in fixed
)
confirmed = vuln_cve and fixed_blocked

proof = []
for f in sorted(os.listdir(logs)):
    if f.startswith(("vulnerable_attempt", "fixed_attempt")) and not f.endswith(".partial.json"):
        proof.append(f"logs/{f}")

artifact_sha256 = {}
for rel in proof:
    with open(os.path.join(logs, os.path.basename(rel)), "rb") as fh:
        artifact_sha256[rel] = hashlib.sha256(fh.read()).hexdigest()

head = os.popen(f"git -C {os.environ['PRUVA_REPO']} rev-parse HEAD").read().strip()
identity = f"git:https://github.com/NousResearch/hermes-agent@{head}"

manifest = {
    "entrypoint_kind": "open_document",
    "entrypoint_detail": "GUI: chat -> open_preview tool -> [Open Preview] / store restore -> preview-pane.tsx <webview partition=persist:hermes-preview> guest -> scripted window.open + synthetic ctrl+meta clicks (no gesture), incl. sandboxed iframe without allow-popups",
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": bool(vuln_reached and fixed_reached),
    "runtime_stack": ["electron", "xvfb", "hermes-python-backend", "mock-openai-provider", "esbuild-renderer-static-server", "local-attacker-http"],
    "target_identity": {
        "repository_url": "https://github.com/NousResearch/hermes-agent",
        "commit_sha": head,
        "target_digest": hashlib.sha256(identity.encode()).hexdigest(),
        "platform": "linux",
        "architecture": "x86_64",
    },
    "proof_artifacts": proof,
    "artifact_sha256": artifact_sha256,
    "notes": f"vulnerable(40.10.2)={vuln} fixed(41.10.3)={fixed} confirmed={confirmed}",
}

with open(os.path.join(repro, "runtime_manifest.json"), "w") as fh:
    json.dump(manifest, fh, indent=2)

print(json.dumps({"vuln": vuln, "fixed": fixed, "confirmed": confirmed}, indent=2))
sys.exit(0 if confirmed else 1)
PYEOF

echo "=== done $(date -u +%FT%TZ) ==="
