diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ed089..2d09c20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: run: | python3 -m pip install --quiet --user --break-system-packages fastapi httpx uvicorn python3 -m unittest trust/tests/test_bridge_shell.py trust/tests/test_shell_helper.py \ - trust/tests/test_bridge_desktop.py + trust/tests/test_bridge_desktop.py trust/tests/test_local_auth.py ui: strategy: diff --git a/bazaar/install.sh b/bazaar/install.sh index 456f11e..5481532 100644 --- a/bazaar/install.sh +++ b/bazaar/install.sh @@ -33,6 +33,12 @@ bazaar::install() { return 1 fi + if ! [[ "$key" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + ui::fail "Invalid tool key: ${key}" + log::error "rejected install: invalid key shape" + return 1 + fi + ui::header "Installing: ${name}" install_dir="${TURINGOS_BAZAAR_DIR}/${key}" marker="${install_dir}/.installed" @@ -99,31 +105,42 @@ bazaar::_install_npx() { local pkg_spec="$package" [[ -n "$version" ]] && pkg_spec="${package}@${version}" - # Verify sha256 of upstream tarball if pinned - if [[ -n "$sha256" && -n "$version" ]]; then - tarball_url=$(npm view "$pkg_spec" dist.tarball 2>/dev/null || echo "") - if [[ -n "$tarball_url" ]] && command -v curl &>/dev/null && (command -v sha256sum &>/dev/null || command -v shasum &>/dev/null); then - ui::info "Verifying SHA-256 for ${pkg_spec}..." - tmp_tarball=$(mktemp "${install_dir}/pkg.XXXXXX.tgz" 2>/dev/null || mktemp /tmp/turingos_pkg.XXXXXX.tgz 2>/dev/null || echo "${install_dir}/pkg.tgz") - if curl --silent --fail --max-time 15 --location "$tarball_url" -o "$tmp_tarball" 2>/dev/null; then - local actual_hash - if command -v sha256sum &>/dev/null; then - actual_hash=$(sha256sum "$tmp_tarball" | awk '{print $1}') - else - actual_hash=$(shasum -a 256 "$tmp_tarball" | awk '{print $1}') - fi - rm -f "$tmp_tarball" 2>/dev/null || true - if [[ "$actual_hash" != "$sha256" ]]; then - ui::fail "SHA-256 mismatch for ${pkg_spec}: expected ${sha256}, got ${actual_hash}" - log::error "sha256 mismatch: key=${key} expected=${sha256} actual=${actual_hash}" - return 1 - fi - ui::ok "SHA-256 verified (${actual_hash:0:12}...)" - else - rm -f "$tmp_tarball" 2>/dev/null || true - fi - fi + # Fail closed: an npm package must be pinned to a version and a sha256, + # and the upstream tarball must hash to exactly that value. + if [[ -z "$sha256" || -z "$version" ]]; then + ui::fail "Refusing to install ${key}: registry entry has no pinned version/sha256" + log::error "unpinned npm entry rejected: key=${key}" + return 1 + fi + if ! command -v curl &>/dev/null || ! (command -v sha256sum &>/dev/null || command -v shasum &>/dev/null); then + ui::fail "Cannot verify ${pkg_spec}: curl and sha256sum/shasum are required" + return 1 + fi + tarball_url=$(npm view "$pkg_spec" dist.tarball 2>/dev/null || echo "") + if [[ -z "$tarball_url" ]]; then + ui::fail "Cannot resolve tarball for ${pkg_spec}; refusing unverified install" + return 1 + fi + ui::info "Verifying SHA-256 for ${pkg_spec}..." + tmp_tarball=$(mktemp "${install_dir}/pkg.XXXXXX.tgz" 2>/dev/null || mktemp /tmp/turingos_pkg.XXXXXX.tgz 2>/dev/null) || return 1 + if ! curl --silent --fail --max-time 15 --location "$tarball_url" -o "$tmp_tarball" 2>/dev/null; then + rm -f "$tmp_tarball" 2>/dev/null || true + ui::fail "Failed to download ${pkg_spec} for verification" + return 1 + fi + local actual_hash + if command -v sha256sum &>/dev/null; then + actual_hash=$(sha256sum "$tmp_tarball" | awk '{print $1}') + else + actual_hash=$(shasum -a 256 "$tmp_tarball" | awk '{print $1}') + fi + rm -f "$tmp_tarball" 2>/dev/null || true + if [[ "$actual_hash" != "$sha256" ]]; then + ui::fail "SHA-256 mismatch for ${pkg_spec}: expected ${sha256}, got ${actual_hash}" + log::error "sha256 mismatch: key=${key} expected=${sha256} actual=${actual_hash}" + return 1 fi + ui::ok "SHA-256 verified (${actual_hash:0:12}...)" # Look the package up without running it (a stdio MCP server never exits) ui::info "Checking npm package: ${pkg_spec}" @@ -176,20 +193,32 @@ bazaar::_install_archive() { fi fi - if [[ -n "$sha256" ]]; then - local actual_hash - if command -v sha256sum &>/dev/null; then - actual_hash=$(sha256sum "$tmp_archive" | awk '{print $1}') - else - actual_hash=$(shasum -a 256 "$tmp_archive" | awk '{print $1}') - fi - if [[ "$actual_hash" != "$sha256" ]]; then - ui::fail "SHA-256 mismatch for ${key}: expected ${sha256}, got ${actual_hash}" - log::error "sha256 mismatch: key=${key} expected=${sha256} actual=${actual_hash}" - rm -f "$tmp_archive" 2>/dev/null || true - return 1 - fi - ui::ok "SHA-256 verified (${actual_hash:0:12}...)" + # Fail closed: archives must carry a pinned sha256. + if [[ -z "$sha256" ]]; then + ui::fail "Refusing to install ${key}: archive entry has no sha256" + log::error "unpinned archive entry rejected: key=${key}" + rm -f "$tmp_archive" 2>/dev/null || true + return 1 + fi + local actual_hash + if command -v sha256sum &>/dev/null; then + actual_hash=$(sha256sum "$tmp_archive" | awk '{print $1}') + else + actual_hash=$(shasum -a 256 "$tmp_archive" | awk '{print $1}') + fi + if [[ "$actual_hash" != "$sha256" ]]; then + ui::fail "SHA-256 mismatch for ${key}: expected ${sha256}, got ${actual_hash}" + log::error "sha256 mismatch: key=${key} expected=${sha256} actual=${actual_hash}" + rm -f "$tmp_archive" 2>/dev/null || true + return 1 + fi + ui::ok "SHA-256 verified (${actual_hash:0:12}...)" + + # Refuse archives with absolute or parent-escaping member paths + if tar -tzf "$tmp_archive" 2>/dev/null | grep -Eq '(^/|(^|/)\.\.(/|$))'; then + ui::fail "Archive contains unsafe paths; refusing to unpack" + rm -f "$tmp_archive" 2>/dev/null || true + return 1 fi mkdir -p "${install_dir}/unpacked" @@ -265,10 +294,22 @@ bazaar::_auto_build() { # ─── Env Var Check ──────────────────────────────────────────────────────────── +# Registry-supplied names are used in ${!name}; anything but a plain identifier +# (e.g. "a[$(cmd)]") would be evaluated by bash, so reject it. +bazaar::_valid_env_name() { + [[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] +} + bazaar::_check_env_vars() { local key="$1" env_var missing=() while IFS= read -r env_var; do - [[ -n "$env_var" && -z "${!env_var:-}" ]] && missing+=("$env_var") + [[ -z "$env_var" ]] && continue + if ! bazaar::_valid_env_name "$env_var"; then + ui::fail "Registry entry ${key} has an invalid env var name; refusing" + log::error "invalid env_required name rejected: key=${key}" + return 1 + fi + [[ -z "${!env_var:-}" ]] && missing+=("$env_var") done < <(registry::get_array "$key" env_required) (( ${#missing[@]} )) || return 0 @@ -301,9 +342,20 @@ bazaar::_edit_claude_config() { bazaar::_register_mcp() { local key="$1" arg var pattern env_vars=() args=() env_args=() mapfile -t env_vars < <(registry::get_array "$key" env_required) + for var in "${env_vars[@]}"; do + bazaar::_valid_env_name "$var" || { log::error "invalid env_required name: key=${key}"; return 1; } + done + + # Pin the registered package to the verified version, not the latest tag + local pkg ver + pkg=$(registry::get_field "$key" package) + ver=$(registry::get_field "$key" version) # Args may reference ${HOME} or a required env var; expand them now while IFS= read -r arg; do + if [[ -n "$pkg" && -n "$ver" && "$arg" == "$pkg" ]]; then + arg="${pkg}@${ver}" + fi for var in HOME "${env_vars[@]}"; do pattern='${'"$var"'}' arg="${arg//"$pattern"/${!var:-}}" diff --git a/bazaar/registry.sh b/bazaar/registry.sh index 0d01421..65e9856 100644 --- a/bazaar/registry.sh +++ b/bazaar/registry.sh @@ -48,12 +48,23 @@ registry::_fetch_remote() { if jq -e '(.entries // .tools) | type == "object"' "$tmp" &>/dev/null; then # Verify Ed25519 detached signature if public key is present pubkey="${TURINGOS_BAZAAR_PUBKEY:-/etc/turingos/trust/bazaar.pub}" - if [[ ! -f "$pubkey" && -f "${ROOT:-.}/trust/keys/bazaar.pub" ]]; then - pubkey="${ROOT:-.}/trust/keys/bazaar.pub" + local root="${TURINGOS_ROOT:-${ROOT:-.}}" + if [[ ! -f "$pubkey" && -f "${root}/trust/keys/bazaar.pub" ]]; then + pubkey="${root}/trust/keys/bazaar.pub" fi - if [[ -f "$pubkey" ]] && command -v openssl &>/dev/null; then + # Fail closed: a trusted key, openssl and a valid signature are all required. + if [[ ! -f "$pubkey" ]] || ! command -v openssl &>/dev/null; then + ui::warn "Bazaar: cannot verify catalog (missing public key or openssl). Rejecting remote index." + log::error "bazaar index not verifiable: pubkey=${pubkey}" + rm -f "$tmp" 2>/dev/null || true + return 1 + fi + { local sig_url="${TURINGOS_BAZAAR_REMOTE_URL}.sig" - tmp_sig=$(mktemp "${cache}.sig.XXXXXX" 2>/dev/null || mktemp /tmp/turingos_bazaar_sig.XXXXXX 2>/dev/null || echo "${cache}.sig") + tmp_sig=$(mktemp "${cache}.sig.XXXXXX" 2>/dev/null || mktemp /tmp/turingos_bazaar_sig.XXXXXX 2>/dev/null) || { + rm -f "$tmp" 2>/dev/null || true + return 1 + } local sig_fetched=0 if [[ "$sig_url" =~ ^file:// ]]; then local sig_path="${sig_url#file://}" @@ -67,17 +78,16 @@ registry::_fetch_remote() { fi fi - if (( sig_fetched )); then - if ! openssl pkeyutl -verify -pubin -inkey "$pubkey" -rawin -in "$tmp" -sigfile "$tmp_sig" &>/dev/null; then - ui::warn "Bazaar index signature verification failed! Rejecting untrusted catalog." - log::error "bazaar index signature verification failed against $pubkey" - rm -f "$tmp" "$tmp_sig" 2>/dev/null || true - return 1 - fi - log::audit BAZAAR_INDEX_VERIFIED "pubkey=${pubkey}" + if (( ! sig_fetched )) || \ + ! openssl pkeyutl -verify -pubin -inkey "$pubkey" -rawin -in "$tmp" -sigfile "$tmp_sig" &>/dev/null; then + ui::warn "Bazaar index signature missing or invalid! Rejecting untrusted catalog." + log::error "bazaar index signature missing/invalid against $pubkey" + rm -f "$tmp" "$tmp_sig" 2>/dev/null || true + return 1 fi + log::audit BAZAAR_INDEX_VERIFIED "pubkey=${pubkey}" rm -f "$tmp_sig" 2>/dev/null || true - fi + } mkdir -p "$(dirname "$cache")" 2>/dev/null || true mv -f "$tmp" "$cache" diff --git a/daemon/src/bazaar.rs b/daemon/src/bazaar.rs index aca16c8..71f36e6 100644 --- a/daemon/src/bazaar.rs +++ b/daemon/src/bazaar.rs @@ -10,8 +10,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; -pub const DEFAULT_REMOTE_URL: &str = - "https://uncoalesced.github.io/turingos-bazaar/v1/index.json"; +pub const DEFAULT_REMOTE_URL: &str = "https://uncoalesced.github.io/turingos-bazaar/v1/index.json"; pub const CACHE_TTL_SECS: u64 = 86400; // 24 hours pub fn bazaar_dir() -> PathBuf { @@ -79,19 +78,23 @@ pub fn public_key_path() -> PathBuf { } pub fn verify_signature(data: &[u8], sig: &[u8]) -> bool { + // Fail closed: any problem verifying means the index is not trusted. let pubkey = public_key_path(); if !pubkey.is_file() { - return true; + return false; } - let tmp_data = cache_path().with_file_name(".verify_data.tmp"); - let tmp_sig = cache_path().with_file_name(".verify_sig.tmp"); + let pid = std::process::id(); + let tmp_data = cache_path().with_file_name(format!(".verify_data.{pid}.tmp")); + let tmp_sig = cache_path().with_file_name(format!(".verify_sig.{pid}.tmp")); if let Some(parent) = tmp_data.parent() { let _ = fs::create_dir_all(parent); } if fs::write(&tmp_data, data).is_err() || fs::write(&tmp_sig, sig).is_err() { - return true; + let _ = fs::remove_file(&tmp_data); + let _ = fs::remove_file(&tmp_sig); + return false; } let status = std::process::Command::new("openssl") @@ -114,7 +117,7 @@ pub fn verify_signature(data: &[u8], sig: &[u8]) -> bool { match status { Ok(s) => s.success(), - Err(_) => true, + Err(_) => false, } } @@ -148,24 +151,28 @@ pub async fn fetch_index() -> (Value, bool) { if let Ok(bytes) = res.bytes().await { if let Ok(parsed) = serde_json::from_slice::(&bytes) { if parsed.get("entries").is_some() || parsed.get("tools").is_some() { - // If detached signature exists, verify it + // A valid detached signature is mandatory. Missing, + // unfetchable or invalid => discard the remote index + // and fall through to stale cache / bundled snapshot. let sig_url = format!("{r_url}.sig"); + let mut verified = false; if let Ok(sig_res) = client.get(&sig_url).send().await { if sig_res.status().is_success() { if let Ok(sig_bytes) = sig_res.bytes().await { - if !verify_signature(&bytes, &sig_bytes) { - eprintln!("turingosd: bazaar index signature verification FAILED!"); - return (parsed, true); - } + verified = verify_signature(&bytes, &sig_bytes); } } } - - if let Some(parent) = cache.parent() { - let _ = fs::create_dir_all(parent); + if verified { + if let Some(parent) = cache.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(&cache, &bytes); + return (parsed, false); } - let _ = fs::write(&cache, &bytes); - return (parsed, false); + eprintln!( + "turingosd: bazaar index signature missing or invalid; ignoring remote index" + ); } } } @@ -380,9 +387,7 @@ pub async fn list(project_path: Option<&str>) -> Value { let (index, offline) = fetch_index().await; let installed = installed_tools(); let project_dir = project_path.map(Path::new); - let disabled = project_dir - .map(get_disabled_items) - .unwrap_or_default(); + let disabled = project_dir.map(get_disabled_items).unwrap_or_default(); let empty_map = serde_json::Map::new(); let entries_map = index diff --git a/daemon/src/control.rs b/daemon/src/control.rs index 4ce35d8..d8cfbaf 100644 --- a/daemon/src/control.rs +++ b/daemon/src/control.rs @@ -35,13 +35,17 @@ pub fn spawn(app: AppRef) { Json(crate::bazaar::list(None).await) } - async fn bazaar_install(axum::extract::Json(params): axum::extract::Json) -> Json { + async fn bazaar_install( + axum::extract::Json(params): axum::extract::Json, + ) -> Json { let key = params.get("key").and_then(Value::as_str).unwrap_or(""); Json(crate::bazaar::install(key).await) } async fn bazaar_rules( - axum::extract::Query(params): axum::extract::Query>, + axum::extract::Query(params): axum::extract::Query< + std::collections::HashMap, + >, ) -> Json { let path = params.get("path").map(String::as_str).unwrap_or("."); Json(crate::bazaar::get_rules(path).await) diff --git a/debian-live/config/includes.chroot/etc/turingos/hyprland.conf b/debian-live/config/includes.chroot/etc/turingos/hyprland.conf index 9a41b1c..3335323 100644 --- a/debian-live/config/includes.chroot/etc/turingos/hyprland.conf +++ b/debian-live/config/includes.chroot/etc/turingos/hyprland.conf @@ -4,9 +4,10 @@ # is fine — this file only needs to define the TuringOS desktop contract. # # The shell window (class turingos-shell — the Brave --app window -# turingos-kiosk opens, always on Chromium's X11 ozone platform so the -# class survives) IS the desktop: pinned to every workspace, maximized -# edge to edge, frameless. Everything else floats above it, opened +# turingos-kiosk opens on Chromium's Wayland ozone platform, where Brave +# ignores --class and takes its app id from the --app URL instead: +# brave-127.0.0.1__-Default) IS the desktop: pinned to every workspace, +# maximized edge to edge, frameless. Everything else floats above it, opened # centered — the cascade. This is the twin of the openbox rules in hook # 0475; the keybinds below are the twin of what openbox's rc.xml provides. @@ -83,7 +84,7 @@ misc { # The shell is the desktop: no frame, all workspaces, full work area windowrule { name = shell-desktop - match:class = ^(turingos-shell)$ + match:class = ^(turingos-shell|(brave|chrome|chromium)-127\.0\.0\.1__.*)$ float = on maximize = on border_size = 0 diff --git a/session/turingos-gfx-detect b/session/turingos-gfx-detect index e7f00fe..9f8f165 100755 --- a/session/turingos-gfx-detect +++ b/session/turingos-gfx-detect @@ -45,13 +45,31 @@ gfx::forced_software() { [[ -n "$boot" && -f "$flag" && "$(cat "$flag" 2>/dev/null)" == "$boot" ]] } -gfx::screen_pixels() { - local size - # awk reads to the end: leaving early would kill xdpyinfo with SIGPIPE, - # which pipefail turns into a failure of the whole line - size="$(xdpyinfo 2>/dev/null | awk '/dimensions:/ && !seen { print $2; seen = 1 }' || true)" +# gfx::screen_size -> "WIDTH HEIGHT", or nothing when no display answers. +# Under Wayland ask the compositor: DISPLAY there is XWayland, which may never +# start, and an X client would then wait on its socket forever. Every probe +# has a timeout so a stuck display cannot hang the session. +gfx::screen_size() { + local size="" + if [[ -n "${WAYLAND_DISPLAY:-}" ]] && command -v hyprctl &>/dev/null; then + # " 1280x800@60.00000 at 0x0" under each monitor + size="$(timeout 3 hyprctl monitors 2>/dev/null \ + | awk '/^[[:space:]]+[0-9]+x[0-9]+@/ && !seen { sub(/@.*/, "", $1); print $1; seen = 1 }' || true)" + fi + if [[ ! "$size" =~ ^[0-9]+x[0-9]+$ && -n "${DISPLAY:-}" ]]; then + # awk reads to the end: leaving early would kill xdpyinfo with SIGPIPE, + # which pipefail turns into a failure of the whole line + size="$(timeout 3 xdpyinfo 2>/dev/null | awk '/dimensions:/ && !seen { print $2; seen = 1 }' || true)" + fi if [[ "$size" =~ ^([0-9]+)x([0-9]+)$ ]]; then - echo $(( BASH_REMATCH[1] * BASH_REMATCH[2] )) + echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}" + fi +} + +gfx::screen_pixels() { + local width height + if read -r width height < <(gfx::screen_size) && [[ -n "$height" ]]; then + echo $(( width * height )) else echo 0 fi diff --git a/session/turingos-kiosk b/session/turingos-kiosk index bbe22f2..e97a624 100755 --- a/session/turingos-kiosk +++ b/session/turingos-kiosk @@ -43,7 +43,7 @@ kiosk::args() { "--user-data-dir=${profile}" \ "--class=turingos-shell" \ "--app=${url}" \ - "--ozone-platform=x11" \ + "--ozone-platform=$(kiosk::ozone)" \ "--window-position=0,0" \ "--window-size=${width},${height}" \ "--no-first-run" \ @@ -80,17 +80,20 @@ kiosk::seed_profile() { } kiosk::screen_size() { - local size - # awk reads to the end: leaving early would kill xdpyinfo with SIGPIPE, - # which pipefail turns into a failure of the whole line - size="$(xdpyinfo 2>/dev/null | awk '/dimensions:/ && !seen { print $2; seen = 1 }' || true)" - if [[ "$size" =~ ^([0-9]+)x([0-9]+)$ ]]; then - echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}" + local width height + if read -r width height < <(gfx::screen_size) && [[ -n "$height" ]]; then + echo "$width $height" else echo "1440 900" fi } +# Wayland when the session has a compositor: under Hyprland the X11 platform +# needs XWayland, and the shell would never appear if it fails to start +kiosk::ozone() { + if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then echo wayland; else echo x11; fi +} + # The bridge is a system service and may still be starting at login kiosk::wait_for_bridge() { local port="$1" tries diff --git a/session/turingos-omni b/session/turingos-omni index 5f07384..0517b90 100755 --- a/session/turingos-omni +++ b/session/turingos-omni @@ -20,14 +20,17 @@ sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/turingos/control.sock" if [ -n "${WAYLAND_DISPLAY:-}" ] && command -v hyprctl >/dev/null 2>&1; then + # On Wayland Brave ignores --class: the shell's app id comes from its URL + # (brave-127.0.0.1__-Default). Same pattern as the rule in hyprland.conf. + is_shell='test("^(turingos-shell|(brave|chrome|chromium)-127\\.0\\.0\\.1__.*)$")' clients="$(hyprctl clients -j 2>/dev/null)" shell="$(printf '%s' "$clients" \ - | jq -r '.[] | select(.class == "turingos-shell") | .address' \ + | jq -r ".[] | select(.class | ${is_shell}) | .address" \ | head -n 1)" printf '%s' "$clients" \ - | jq -r '.[] | select(.class != "turingos-shell" - and .workspace.name != "special:turingos-omni") - | .address' \ + | jq -r ".[] | select((.class | ${is_shell} | not) + and .workspace.name != \"special:turingos-omni\") + | .address" \ | while IFS= read -r addr; do [ -n "$addr" ] || continue hyprctl dispatch focuswindow "address:$addr" >/dev/null 2>&1 diff --git a/tests/bazaar_fallback.sh b/tests/bazaar_fallback.sh index dca38c6..1e07d80 100644 --- a/tests/bazaar_fallback.sh +++ b/tests/bazaar_fallback.sh @@ -103,7 +103,8 @@ CORRUPT_TEMP="${WORK}/corrupt.json" echo "502 Bad Gateway" > "$CORRUPT_TEMP" # If curl fetched this garbage, _fetch_remote must reject it and NOT overwrite the cache -export TURINGOS_BAZAAR_REMOTE_URL="$(to_file_url "${CORRUPT_TEMP}")" +TURINGOS_BAZAAR_REMOTE_URL="$(to_file_url "${CORRUPT_TEMP}")" +export TURINGOS_BAZAAR_REMOTE_URL rm -f "$TURINGOS_BAZAAR_CACHE" # Load must fall back to bundled snapshot without crashing on jq error keys=$(registry::keys) @@ -245,7 +246,8 @@ EOF openssl pkeyutl -sign -inkey "$TEST_KEY" -rawin -in "$TEST_INDEX" -out "$TEST_SIG" -export TURINGOS_BAZAAR_REMOTE_URL="$(to_file_url "${TEST_INDEX}")" +TURINGOS_BAZAAR_REMOTE_URL="$(to_file_url "${TEST_INDEX}")" +export TURINGOS_BAZAAR_REMOTE_URL export TURINGOS_BAZAAR_PUBKEY="$TEST_PUB" rm -f "$TURINGOS_BAZAAR_CACHE" @@ -263,4 +265,20 @@ registry::_load keys=$(registry::keys) grep -q "memory-mcp" <<< "$keys" || fail "tampered index should fall back to bundled snapshot" +# 10c. Missing signature must be rejected (no downgrade by stripping the .sig) +echo '{"entries": {"unsigned": {"id": "unsigned", "name": "Unsigned"}}}' > "$TEST_INDEX" +rm -f "$TEST_SIG" "$TURINGOS_BAZAAR_CACHE" +registry::_load +(( TURINGOS_BAZAAR_OFFLINE == 1 )) || fail "index with missing .sig should be rejected" +keys=$(registry::keys) +! grep -q "unsigned" <<< "$keys" || fail "unsigned index entries must not be loaded" + +# 10d. Missing public key must be rejected (fail closed) +openssl pkeyutl -sign -inkey "$TEST_KEY" -rawin -in "$TEST_INDEX" -out "$TEST_SIG" +export TURINGOS_BAZAAR_PUBKEY="${SIG_TEST_DIR}/does-not-exist.pub" +export TURINGOS_ROOT="${SIG_TEST_DIR}/no-root" +rm -f "$TURINGOS_BAZAAR_CACHE" +registry::_load +(( TURINGOS_BAZAAR_OFFLINE == 1 )) || fail "missing pubkey should reject remote index" + echo "==> All Bazaar Fallback, Integrity, Per-Project Rule, and Ed25519 Verification tests passed successfully!" diff --git a/tests/benchmark.sh b/tests/benchmark.sh index 806e17b..3f03d06 100644 --- a/tests/benchmark.sh +++ b/tests/benchmark.sh @@ -18,6 +18,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPORT_FILE="${ROOT}/benchmark_report.json" +# shellcheck disable=SC2034 # --fast is accepted and reserved for quicker runs FAST_MODE=0 while [[ $# -gt 0 ]]; do @@ -28,6 +29,7 @@ while [[ $# -gt 0 ]]; do esac done +export FAST_MODE # reserved flag; exported so it is visibly used RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -37,6 +39,7 @@ NC='\033[0m' PASSED_COUNT=0 FAILED_COUNT=0 +# shellcheck disable=SC2034 TOTAL_START=$(date +%s%N 2>/dev/null || date +%s) declare -a BENCHMARK_RESULTS=() @@ -74,6 +77,7 @@ run_suite() { record_benchmark "$suite_name" "PASS" "$duration_ms" "Clean execution" else echo -e " ${RED}✗ FAILED${NC} (${duration_ms}ms) with exit code $rc" + # shellcheck disable=SC2001 # prefix every line, not a substitution echo "$output" | sed 's/^/ /' FAILED_COUNT=$((FAILED_COUNT + 1)) record_benchmark "$suite_name" "FAIL" "$duration_ms" "Exited with code $rc" @@ -106,6 +110,8 @@ run_suite "Bazaar 10-Step Cryptographic & Cache Suite" "bash '${ROOT}/tests/baza if command -v cargo &>/dev/null; then if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]] && [[ -z "${LIBCLANG_PATH:-}" ]]; then echo -e " ${YELLOW}⚠ Skipping daemon cargo check on Windows host without LIBCLANG_PATH (run inside Linux VM for full clang bindgen check)${NC}" + elif ! pkg-config --exists alsa 2>/dev/null && [[ "$OSTYPE" != "msys" && "$OSTYPE" != "win32" ]]; then + echo -e " ${YELLOW}⚠ Skipping daemon cargo check (ALSA dev libraries missing; the CI daemon job builds it in the ISO's Debian container)${NC}" else run_suite "Daemon Compilation & Feature Verification (turingosd)" "cd '${ROOT}/daemon' && cargo check" fi @@ -114,6 +120,7 @@ else fi # ── 6. Visual Theme & Window Border Compliance Benchmark ────────────────────── +# shellcheck disable=SC2317,SC2329 # run via run_suite test_border_compliance() { local hypr="${ROOT}/debian-live/config/includes.chroot/etc/turingos/hyprland.conf" local tokens="${ROOT}/ui/web/css/tokens.css" @@ -134,6 +141,7 @@ test_border_compliance() { run_suite "Renewed Palette Window Border & Token Compliance" "test_border_compliance" # ── 7. Kernel Persistence Cmdline Benchmark ─────────────────────────────────── +# shellcheck disable=SC2317,SC2329 # run via run_suite test_live_build_persistence() { local build_amd="${ROOT}/debian-live/build-amd64.sh" local build_arm="${ROOT}/arm/build.sh" @@ -146,6 +154,7 @@ test_live_build_persistence() { run_suite "Live-Build Persistence & Dual-Naming Verification" "test_live_build_persistence" # ── 8. Calamares Installer Persistent User Autologin Benchmark ──────────────── +# shellcheck disable=SC2317,SC2329 # run via run_suite test_installer_autologin_hardening() { local hook="${ROOT}/debian-live/config/hooks/normal/0495-installer.hook.chroot" grep -q "PUSER=" "$hook" || return 1 diff --git a/tests/kiosk_display.sh b/tests/kiosk_display.sh new file mode 100755 index 0000000..8d70a63 --- /dev/null +++ b/tests/kiosk_display.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# tests/kiosk_display.sh — the shell finds the screen size without hanging and +# picks Chromium's ozone platform from the session: Wayland under Hyprland, +# X11 under openbox. A 10-19 build sat on a black screen because xdpyinfo +# waited forever on an XWayland that never started. +# +# hyprctl and xdpyinfo are stubs on PATH, so this runs anywhere with bash. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } + +STUBS="${WORK}/bin" +mkdir -p "$STUBS" +# stub NAME BODY — a fake command on PATH +stub() { printf '#!/usr/bin/env bash\n%s\n' "$2" > "${STUBS}/$1"; chmod +x "${STUBS}/$1"; } +monitors='printf "Monitor Virtual-1 (ID 0):\n\t1280x800@60.00000 at 0x0\n\tscale: 1.00\n"' +dimensions='printf "screen #0:\n dimensions: 1920x1080 pixels (508x285 millimeters)\n"' + +# run ENV... — kiosk::screen_size in a clean shell with the stubs first on PATH; +# prints "SECONDS WIDTH HEIGHT" +run() { + env -i PATH="${STUBS}:/usr/bin:/bin" HOME="$WORK" "$@" bash -c ' + source "$1" + start=$SECONDS + read -r w h < <(kiosk::screen_size) + echo "$(( SECONDS - start )) $w $h" + ' _ "${ROOT}/session/turingos-kiosk" +} + +expect() { # expect DESCRIPTION "W H" OUTPUT + local elapsed size + read -r elapsed size <<<"$3" + [[ "$size" == "$2" ]] || fail "$1: got ${size:-nothing}, want $2" + # Two probes of 3s each at worst; a hang would be the stubs' 60s + (( elapsed <= 8 )) || fail "$1: took ${elapsed}s" +} + +# Hyprland answers: its size wins, and X is never asked +stub hyprctl "$monitors" +stub xdpyinfo 'touch "'"$WORK"'/xdpyinfo-ran"; sleep 60' +expect "Wayland with hyprctl" "1280 800" "$(run WAYLAND_DISPLAY=wayland-1 DISPLAY=:0)" +[[ ! -e "${WORK}/xdpyinfo-ran" ]] || fail "xdpyinfo ran although hyprctl answered" + +# XWayland never starts: xdpyinfo hangs, the probe gives up and uses the default +rm -f "${STUBS}/hyprctl" +expect "a hanging X display" "1440 900" "$(run DISPLAY=:0)" + +# hyprctl hangs too: still bounded +stub hyprctl 'sleep 60' +expect "a hanging compositor and X display" "1440 900" "$(run WAYLAND_DISPLAY=wayland-1 DISPLAY=:0)" + +# Openbox: plain X answers +rm -f "${STUBS}/hyprctl" +stub xdpyinfo "$dimensions" +expect "X11" "1920 1080" "$(run DISPLAY=:0)" + +# No display at all +expect "no display" "1440 900" "$(run)" + +# gfx::screen_pixels uses the same probe +pixels="$(env -i PATH="${STUBS}:/usr/bin:/bin" DISPLAY=:0 bash -c 'source "$1"; gfx::screen_pixels' _ "${ROOT}/session/turingos-gfx-detect")" +[[ "$pixels" == $(( 1920 * 1080 )) ]] || fail "gfx::screen_pixels: got ${pixels}" + +# Ozone platform follows the session +args() { + env -i PATH="/usr/bin:/bin" "$@" bash -c 'source "$1"; kiosk::args gpu full http://x 1 1 /p' \ + _ "${ROOT}/session/turingos-kiosk" +} +args WAYLAND_DISPLAY=wayland-1 DISPLAY=:0 | grep -qx -- '--ozone-platform=wayland' || fail "Hyprland session did not get Wayland ozone" +args DISPLAY=:0 | grep -qx -- '--ozone-platform=x11' || fail "openbox session did not get X11 ozone" + +# On Wayland Brave ignores --class (seen in a VM: brave-127.0.0.1__-Default), +# so the Hyprland rule and Super+Space must both know that app id +rule="$(sed -n 's/^[[:space:]]*match:class = \(.*turingos-shell.*\)$/\1/p' \ + "${ROOT}/debian-live/config/includes.chroot/etc/turingos/hyprland.conf")" +[[ -n "$rule" ]] || fail "hyprland.conf has no shell-window rule" +for class in turingos-shell brave-127.0.0.1__-Default; do + grep -qE "$rule" <<<"$class" || fail "the Hyprland shell rule misses ${class}" +done +grep -qE "$rule" <<<"brave-browser" && fail "the Hyprland shell rule catches ordinary Brave windows" +if command -v jq &>/dev/null; then + # Super+Space under Hyprland: the other window is stashed, the shell focused + stub hyprctl ' +case "$1 $2" in + "clients -j") echo "[{\"class\":\"brave-127.0.0.1__-Default\",\"address\":\"0xshell\",\"workspace\":{\"name\":\"1\"}},{\"class\":\"foot\",\"address\":\"0xfoot\",\"workspace\":{\"name\":\"1\"}}]" ;; + "monitors -j") echo "[{}]" ;; + *) echo "$*" >> "'"$WORK"'/dispatched" ;; +esac' + env -i PATH="${STUBS}:$(dirname "$(command -v jq)"):/usr/bin:/bin" WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR="$WORK" \ + sh "${ROOT}/session/turingos-omni" >/dev/null 2>&1 || true + grep -qx 'dispatch focuswindow address:0xfoot' "${WORK}/dispatched" || fail "Super+Space did not stash the other window" + [[ "$(grep -c movetoworkspacesilent "${WORK}/dispatched")" == 1 ]] || fail "Super+Space stashed the shell window itself" + [[ "$(grep focuswindow "${WORK}/dispatched" | tail -n 1)" == 'dispatch focuswindow address:0xshell' ]] \ + || fail "Super+Space did not focus the shell" +fi + +echo "OK: kiosk display probe and ozone platform" diff --git a/trust/daemons/agentd-plan.py b/trust/daemons/agentd-plan.py index 105b2f3..bd22c14 100755 --- a/trust/daemons/agentd-plan.py +++ b/trust/daemons/agentd-plan.py @@ -75,10 +75,34 @@ def create(self, session_id: str, intent: str, steps: list[dict]) -> dict: def get(self, plan_id: str) -> dict | None: return self.plans.get(plan_id) + def reject(self, plan_id: str, session_id: str) -> dict: + plan = self.plans.get(plan_id) + if not plan: + return {"status": "error", "errors": [f"plan not found: {plan_id}"]} + if plan["status"] != "pending_approval": + return {"status": "error", "errors": [f"plan is {plan['status']}, cannot reject"]} + + plan["status"] = "rejected" + audit({ + "version": 1, + "timestamp": datetime.now(timezone.utc).isoformat(), + "subject": {"kind": "user", "id": "local"}, + "session_id": session_id, + "action": {"kind": "plan.reject", "plan_id": plan_id}, + "provenance": {"origin": "user_request", "taint": "trusted", "chain": []}, + "outcome": "denied", + }) + # wake the waiter: it sees a non-approved plan and does nothing + if plan_id in self.approval_events: + self.approval_events[plan_id].set() + return {"status": "ok", "plan_id": plan_id, "rejected": True} + def approve(self, plan_id: str, session_id: str) -> dict: plan = self.plans.get(plan_id) if not plan: return {"status": "error", "errors": [f"plan not found: {plan_id}"]} + if plan["status"] != "pending_approval": + return {"status": "error", "errors": [f"plan is {plan['status']}, cannot approve"]} now = datetime.now(timezone.utc).isoformat() plan["approval"]["granted_by"] = "local" @@ -125,6 +149,11 @@ async def execute_plan(plan: dict) -> dict: plan_id = plan["plan_id"] results = [] + # Only an approved plan runs, and only once + if plan["status"] != "approved": + return {"status": "error", "errors": [f"plan is {plan['status']}, not approved"]} + plan["status"] = "executing" + audit({ "version": 1, "timestamp": datetime.now(timezone.utc).isoformat(), @@ -136,7 +165,7 @@ async def execute_plan(plan: dict) -> dict: }) for step in plan["steps"]: - if plan["status"] != "approved": + if plan["status"] != "executing": step["status"] = "cancelled" results.append({"step_id": step["step_id"], "error": "plan not approved"}) continue @@ -186,6 +215,7 @@ async def execute_plan(plan: dict) -> dict: "outcome": "complete", }) + plan["status"] = "completed" return {"status": "ok", "plan_id": plan["plan_id"], "results": results} @@ -203,7 +233,7 @@ async def execute_plan_after_approval(plan_id: str): async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): try: - data = await reader.read(4096) + data = await asyncio.wait_for(reader.readline(), 30) if not data: return @@ -229,7 +259,16 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit asyncio.create_task(execute_plan_after_approval(plan["plan_id"])) elif action == "approve_plan": - result = plan_store.approve(msg.get("plan_id", ""), msg.get("session_id", "unknown")) + if msg.get("rejected"): + # older pages sent reject as approve_plan + rejected:true + result = plan_store.reject(msg.get("plan_id", ""), msg.get("session_id", "unknown")) + else: + result = plan_store.approve(msg.get("plan_id", ""), msg.get("session_id", "unknown")) + writer.write((json.dumps(result) + "\n").encode()) + await writer.drain() + + elif action == "reject_plan": + result = plan_store.reject(msg.get("plan_id", ""), msg.get("session_id", "unknown")) writer.write((json.dumps(result) + "\n").encode()) await writer.drain() @@ -262,7 +301,9 @@ async def run_daemon(socket_path: Path): if socket_path.exists(): os.unlink(socket_path) - server = await asyncio.start_unix_server(handle_client, path=str(socket_path)) + server = await asyncio.start_unix_server( + handle_client, path=str(socket_path), limit=4 * 1024 * 1024 + ) os.chmod(socket_path, 0o666) print(f"turingos-agentd-plan listening on {socket_path}", file=sys.stderr) diff --git a/trust/daemons/bridged-ws.py b/trust/daemons/bridged-ws.py index 1f2734b..0a8c647 100644 --- a/trust/daemons/bridged-ws.py +++ b/trust/daemons/bridged-ws.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 import argparse import asyncio +import ipaddress import json import os +import stat from pathlib import Path import uvicorn @@ -37,6 +39,7 @@ def _app_dir() -> Path: # so run_shell() forwards to shell-helper.py (a per-session user unit) — the # 60s/64KB budget lives there; this is just how long we wait for it. SHELL_TIMEOUT = 60 +REPLY_MAX_LINE = 4 * 1024 * 1024 LOCAL_HOSTS = {"localhost", "127.0.0.1"} app = FastAPI(title="TuringOS Bridge") @@ -64,12 +67,15 @@ def __init__(self, socket_path: str): self.socket_path = socket_path async def send(self, msg: dict) -> dict: - loop = asyncio.get_event_loop() try: - reader, writer = await asyncio.open_unix_connection(self.socket_path) + # Replies are one JSON line, but can exceed 4 KB (model output, + # fetched pages, grant lists): read the line, not one chunk + reader, writer = await asyncio.open_unix_connection( + self.socket_path, limit=REPLY_MAX_LINE + ) writer.write((json.dumps(msg) + "\n").encode()) await writer.drain() - data = await reader.read(4096) + data = await asyncio.wait_for(reader.readline(), 120) writer.close() await writer.wait_closed() return json.loads(data.decode().strip()) @@ -100,6 +106,8 @@ async def plan_ws(ws: WebSocket): result = await plan_client.send({"action": "create_plan", **msg}) elif action == "approve_plan": result = await plan_client.send({"action": "approve_plan", **msg}) + elif action == "reject_plan": + result = await plan_client.send({"action": "reject_plan", **msg}) elif action == "execute_plan": result = await plan_client.send({"action": "execute_plan", **msg}) else: @@ -148,7 +156,11 @@ async def bridge_ws(ws: WebSocket): action = msg.get("action") if action == "fetch": - result = await bridge_client.send({"action": "fetch", **msg}) + # Only the URL goes through: the egress allowlist is bridged's + # own, a page must not be able to widen it + result = await bridge_client.send( + {"action": "fetch", "url": str(msg.get("url", ""))} + ) await ws.send_text(json.dumps(result)) else: await ws.send_text(json.dumps({"status": "error", "errors": [f"unknown bridge action: {action}"]})) @@ -220,28 +232,111 @@ def origin_allowed(ws: WebSocket) -> bool: """Only the UI page this bridge serves may open a WebSocket. Browsers don't apply CORS to WebSockets, so without this any site open in Brave could run commands or drive the agent; a DNS-rebound name fails the - localhost check.""" + localhost check. Headers are forgeable by any local process though, so the + connecting process must also belong to the logged-in user (peer_allowed).""" host = ws.headers.get("host", "") return ( host.rsplit(":", 1)[0] in LOCAL_HOSTS and ws.headers.get("origin", "") == f"http://{host}" + and peer_allowed(ws) ) +def _tcp_hex_addr(addr: "ipaddress.IPv4Address | ipaddress.IPv6Address") -> str: + """An address as /proc/net/tcp{,6} prints it: each 32-bit word of the + packed address byte-reversed, upper-case hex.""" + packed = addr.packed + return "".join(packed[i : i + 4][::-1].hex() for i in range(0, len(packed), 4)).upper() + + +def tcp_peer_uid(host: str, port: int) -> "int | None": + """uid of the local process that owns the client end (host, port) of a + loopback TCP connection, from the kernel's socket table. None if it can't + be determined (not Linux, not loopback, no such socket).""" + try: + addr = ipaddress.ip_address(host) + except ValueError: + return None + if not addr.is_loopback and not getattr(addr, "ipv4_mapped", None): + return None + want = f"{_tcp_hex_addr(addr)}:{port:04X}" + for table in ("/proc/net/tcp", "/proc/net/tcp6"): + try: + with open(table) as f: + rows = f.read().splitlines()[1:] + except OSError: + continue + for row in rows: + fields = row.split() + if len(fields) > 7 and fields[1].upper() == want: + try: + return int(fields[7]) + except ValueError: + return None + return None + + +def session_uids() -> "set[int]": + """uids of the logged-in sessions: owners of the helper sockets in the + drop-box (see session_socks), plus TURINGOS_BRIDGE_ALLOW_UIDS.""" + uids = {uid for uid, _ in session_socks("shell")} | { + uid for uid, _ in session_socks("desktop") + } + extra = os.environ.get("TURINGOS_BRIDGE_ALLOW_UIDS", "") + uids |= {int(u) for u in extra.split(",") if u.strip().isdigit()} + return uids + + +def peer_allowed(ws: WebSocket) -> bool: + """The process behind this WebSocket must run as a logged-in user. + Fails closed when the peer can't be identified. With no session yet there + is nothing to match against, and /shell and /desktop have nothing to talk + to anyway. TURINGOS_BRIDGE_PEER_CHECK=0 turns the check off (tests, dev).""" + if os.environ.get("TURINGOS_BRIDGE_PEER_CHECK", "1") == "0": + return True + uids = session_uids() + if not uids: + return True + client = ws.client + if client is None: + return False + uid = tcp_peer_uid(client.host, client.port) + return uid is not None and uid in uids + + def session_dir() -> Path: # 1777 drop-box (turingos-tmpfiles.conf): each session's helper drops # .sock here; read at call time so tests can point it anywhere return Path(os.environ.get("TURINGOS_SESSION_DIR", "/run/turingos/session")) +def session_socks(kind: str) -> "list[tuple[int, Path]]": + """(uid, path) of live helper sockets, newest first. kind is "shell" + (.sock) or "desktop" (.desktop.sock). The directory is world + writable, so a socket only counts if its owner is the uid in its name: + another user can't plant one to capture what the page sends.""" + found = [] + for p in session_dir().glob("[0-9]*.sock"): + is_desktop = p.name.endswith(".desktop.sock") + if (kind == "desktop") != is_desktop: + continue + prefix = p.name.split(".", 1)[0] + if not prefix.isdigit(): + continue + try: + st = p.stat() + except OSError: + continue + if stat.S_ISSOCK(st.st_mode) and st.st_uid == int(prefix): + found.append((st.st_mtime, int(prefix), p)) + found.sort(key=lambda t: t[0], reverse=True) + return [(uid, p) for _, uid, p in found] + + async def run_shell(command: str, timeout: float = SHELL_TIMEOUT + 15) -> dict: """Forward to the login session's shell-helper so commands run as the person at the keyboard, not as the system user `turingos`.""" - socks = sorted( - (p for p in session_dir().glob("*.sock") if p.is_socket()), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) + socks = [p for _, p in session_socks("shell")] if not socks: return { "status": "error", @@ -307,12 +402,8 @@ async def shell_ws(ws: WebSocket): def desktop_socket(): - socks = sorted( - (p for p in session_dir().glob("*.desktop.sock") if p.is_socket()), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - return socks[0] if socks else None + socks = session_socks("desktop") + return socks[0][1] if socks else None @app.websocket("/desktop") diff --git a/trust/daemons/bridged.py b/trust/daemons/bridged.py index 6cd3c58..7f81c4f 100755 --- a/trust/daemons/bridged.py +++ b/trust/daemons/bridged.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import ipaddress import json import os import socket @@ -30,18 +31,69 @@ def audit(entry: dict): pass +MAX_FETCH_BYTES = 5 * 1024 * 1024 + + +def server_allowlist() -> list[str]: + """The allowlist is decided here, never by the caller: any local process + can reach this socket. Operators extend it via TURINGOS_BRIDGE_ALLOWLIST + (comma-separated hostnames).""" + extra = os.environ.get("TURINGOS_BRIDGE_ALLOWLIST", "") + return DEFAULT_ALLOWLIST + [d.strip() for d in extra.split(",") if d.strip()] + + def is_allowed(domain: str, allowlist: list[str]) -> bool: + if not domain: + return False for allowed in allowlist: - if domain == allowed or domain.endswith("." + allowed): + if allowed and (domain == allowed or domain.endswith("." + allowed)): + return True + return False + + +def resolves_to_private(host: str) -> bool: + """True if any address for host is loopback/private/link-local/etc, which + would turn an allowed name into a way to reach local services (SSRF).""" + try: + infos = socket.getaddrinfo(host, None) + except OSError: + return True + for info in infos: + try: + addr = ipaddress.ip_address(info[4][0].split("%", 1)[0]) + except ValueError: + return True + if ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_multicast + or addr.is_reserved + or addr.is_unspecified + ): return True return False +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """A redirect could leave the allowlist, so never follow one.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + def fetch_url(url: str, allowlist: list[str]) -> dict: from urllib.parse import urlparse parsed = urlparse(url) domain = parsed.hostname or "" + if parsed.scheme not in ("http", "https"): + return { + "status": "error", + "errors": [f"unsupported URL scheme: {parsed.scheme or 'none'}"], + "taint": "untrusted", + } + if not is_allowed(domain, allowlist): return { "status": "error", @@ -49,10 +101,25 @@ def fetch_url(url: str, allowlist: list[str]) -> dict: "taint": "untrusted", } + if resolves_to_private(domain): + return { + "status": "error", + "errors": [f"refusing to fetch non-public address: {domain}"], + "taint": "untrusted", + } + try: req = urllib.request.Request(url, headers={"User-Agent": "TuringOS/1.2"}) - response = urllib.request.urlopen(req, timeout=30) - content = response.read().decode("utf-8", errors="replace") + opener = urllib.request.build_opener(_NoRedirect) + response = opener.open(req, timeout=30) + raw = response.read(MAX_FETCH_BYTES + 1) + if len(raw) > MAX_FETCH_BYTES: + return { + "status": "error", + "errors": [f"response larger than {MAX_FETCH_BYTES} bytes"], + "taint": "untrusted", + } + content = raw.decode("utf-8", errors="replace") audit({ "version": 1, @@ -96,8 +163,8 @@ def handle_client(client: socket.socket): action = msg.get("action") if action == "fetch": url = msg.get("url", "") - allowlist = msg.get("allowlist", DEFAULT_ALLOWLIST) - result = fetch_url(url, allowlist) + # A client-supplied "allowlist" is deliberately ignored + result = fetch_url(url, server_allowlist()) client.sendall(json.dumps(result).encode() + b"\n") elif action == "health": client.sendall(json.dumps({"status": "ok", "service": "bridged"}).encode() + b"\n") diff --git a/trust/daemons/shell-helper.py b/trust/daemons/shell-helper.py index 4d93eaf..92b5842 100644 --- a/trust/daemons/shell-helper.py +++ b/trust/daemons/shell-helper.py @@ -115,7 +115,10 @@ async def handle(reader, writer): line = await asyncio.wait_for(reader.readline(), 10) msg = json.loads(line.decode(errors="replace") or "{}") command = str(msg.get("command", "")).strip() - if uid >= 0 and uid not in allowed_uids(): + if uid < 0 and hasattr(socket, "SO_PEERCRED"): + # Linux can always say who connected; failing to means deny + reply = {"status": "error", "errors": ["cannot identify peer; refusing"]} + elif uid >= 0 and uid not in allowed_uids(): reply = {"status": "error", "errors": [f"peer uid {uid} may not run commands"]} elif not command: reply = {"status": "error", "errors": ["missing command"]} diff --git a/trust/schemas/audit-entry.json b/trust/schemas/audit-entry.json index 22e0cf8..6311fad 100644 --- a/trust/schemas/audit-entry.json +++ b/trust/schemas/audit-entry.json @@ -51,7 +51,7 @@ "properties": { "kind": { "type": "string", - "enum": ["fs.read", "fs.write", "fs.execute", "net.egress", "sandbox.spawn", "memory.read", "memory.write", "bridge.egress", "grant.issue", "grant.revoke", "process.kill", "plan.create", "plan.approve", "plan.execute", "ui.create", "ui.dismiss", "ui.ttl_expire"] + "enum": ["fs.read", "fs.write", "fs.execute", "net.egress", "sandbox.spawn", "memory.read", "memory.write", "bridge.egress", "grant.issue", "grant.revoke", "process.kill", "plan.create", "plan.approve", "plan.reject", "plan.execute", "ui.create", "ui.dismiss", "ui.ttl_expire"] }, "path": { "type": "string" diff --git a/trust/tests/test_bridge_desktop.py b/trust/tests/test_bridge_desktop.py index 33b33c2..6a76a97 100644 --- a/trust/tests/test_bridge_desktop.py +++ b/trust/tests/test_bridge_desktop.py @@ -67,10 +67,12 @@ class Desktop(unittest.TestCase): def setUp(self): self.dir = tempfile.mkdtemp(prefix="tos-desk-") os.environ["TURINGOS_SESSION_DIR"] = self.dir + os.environ["TURINGOS_BRIDGE_PEER_CHECK"] = "0" # see test_local_auth.py self.client = TestClient(bridge.app) def tearDown(self): os.environ.pop("TURINGOS_SESSION_DIR", None) + os.environ.pop("TURINGOS_BRIDGE_PEER_CHECK", None) shutil.rmtree(self.dir, ignore_errors=True) def test_frames_go_through_both_ways(self): diff --git a/trust/tests/test_bridge_shell.py b/trust/tests/test_bridge_shell.py index 726fecd..2a673d0 100644 --- a/trust/tests/test_bridge_shell.py +++ b/trust/tests/test_bridge_shell.py @@ -36,6 +36,9 @@ def setUpModule(): _session_dir = tempfile.mkdtemp(prefix="turingos-session-") os.environ["TURINGOS_SESSION_DIR"] = _session_dir os.environ["TURINGOS_SHELL_ALLOW_UIDS"] = str(os.getuid()) + # TestClient's peer isn't a real TCP connection; test_local_auth.py + # covers the peer check itself + os.environ["TURINGOS_BRIDGE_PEER_CHECK"] = "0" _helper_proc = subprocess.Popen( [sys.executable, str(HELPER)], stdout=subprocess.DEVNULL, @@ -68,6 +71,7 @@ def tearDownModule(): shutil.rmtree(_session_dir, ignore_errors=True) os.environ.pop("TURINGOS_SESSION_DIR", None) os.environ.pop("TURINGOS_SHELL_ALLOW_UIDS", None) + os.environ.pop("TURINGOS_BRIDGE_PEER_CHECK", None) def connect(host: str, origin: str): diff --git a/trust/tests/test_local_auth.py b/trust/tests/test_local_auth.py new file mode 100644 index 0000000..e84bfb0 --- /dev/null +++ b/trust/tests/test_local_auth.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Local-access hardening: bridged's egress policy is the server's, not the +caller's; the bridge only trusts pages from a logged-in user's own processes +and only helper sockets owned by the uid in their name; shell-helper refuses +peers it cannot identify; a rejected plan stays rejected and a plan runs once. + + python3 -m unittest trust/tests/test_local_auth.py (needs fastapi, httpx) +""" +import asyncio +import http.server +import importlib.util +import json +import os +import shutil +import socket +import tempfile +import threading +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +DAEMONS = Path(__file__).parent.parent / "daemons" + + +def load(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, DAEMONS / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +bridged = load("bridged_local_auth", "bridged.py") +bridge = load("bridged_ws_local_auth", "bridged-ws.py") +helper = load("shell_helper_local_auth", "shell-helper.py") +planner = load("agentd_plan_local_auth", "agentd-plan.py") + + +class BridgedEgress(unittest.TestCase): + def test_allowlist_belongs_to_the_server(self): + self.assertEqual(bridged.server_allowlist(), bridged.DEFAULT_ALLOWLIST) + with mock.patch.dict(os.environ, {"TURINGOS_BRIDGE_ALLOWLIST": "a.example, b.example"}): + self.assertEqual( + bridged.server_allowlist(), + bridged.DEFAULT_ALLOWLIST + ["a.example", "b.example"], + ) + + def test_empty_domain_never_matches(self): + self.assertFalse(bridged.is_allowed("", [""])) + self.assertFalse(bridged.is_allowed("", bridged.DEFAULT_ALLOWLIST)) + + def test_only_http_and_https(self): + res = bridged.fetch_url("file:///etc/passwd", [""]) + self.assertEqual(res["status"], "error") + self.assertIn("scheme", res["errors"][0]) + + def test_unlisted_domain_refused(self): + res = bridged.fetch_url("http://evil.example/", bridged.DEFAULT_ALLOWLIST) + self.assertIn("allowlist", res["errors"][0]) + + def test_allowed_name_cannot_reach_loopback(self): + res = bridged.fetch_url("http://127.0.0.1:9/", ["127.0.0.1"]) + self.assertEqual(res["status"], "error") + self.assertIn("non-public", res["errors"][0]) + + def test_client_cannot_widen_the_allowlist(self): + a, b = socket.socketpair() + try: + req = {"action": "fetch", "url": "http://127.0.0.1:1/", "allowlist": ["127.0.0.1"]} + a.sendall(json.dumps(req).encode() + b"\n") + bridged.handle_client(b) + reply = json.loads(a.recv(65536).decode()) + finally: + a.close() + self.assertEqual(reply["status"], "error") + self.assertIn("allowlist", reply["errors"][0]) + + def _serve(self, handler_cls): + server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls) + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + return server.server_address[1] + + def test_redirects_are_not_followed(self): + class Redirect(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(302) + self.send_header("Location", "http://other.invalid/") + self.end_headers() + + def log_message(self, *args): + pass + + port = self._serve(Redirect) + # the test server is on loopback; skip only the address check + with mock.patch.object(bridged, "resolves_to_private", return_value=False): + res = bridged.fetch_url(f"http://127.0.0.1:{port}/", ["127.0.0.1"]) + self.assertEqual(res["status"], "error") + + def test_plain_page_comes_back_untrusted(self): + class Page(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"hello") + + def log_message(self, *args): + pass + + port = self._serve(Page) + with mock.patch.object(bridged, "resolves_to_private", return_value=False): + res = bridged.fetch_url(f"http://127.0.0.1:{port}/", ["127.0.0.1"]) + self.assertEqual((res["status"], res["content"], res["taint"]), ("ok", "hello", "untrusted")) + + +class SessionSockets(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="tos-auth-") + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + env = mock.patch.dict(os.environ, {"TURINGOS_SESSION_DIR": self.dir}) + env.start() + self.addCleanup(env.stop) + + def bind(self, name: str): + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.bind(str(Path(self.dir, name))) + self.addCleanup(s.close) + + def test_shell_and_desktop_are_told_apart(self): + uid = os.getuid() + self.bind(f"{uid}.sock") + self.bind(f"{uid}.desktop.sock") + shell = bridge.session_socks("shell") + desktop = bridge.session_socks("desktop") + self.assertEqual([u for u, _ in shell], [uid]) + self.assertEqual([u for u, _ in desktop], [uid]) + self.assertEqual(shell[0][1].name, f"{uid}.sock") + self.assertEqual(desktop[0][1].name, f"{uid}.desktop.sock") + + def test_a_socket_named_for_someone_else_is_ignored(self): + # the drop-box is world-writable: owner must match the uid in the name + self.bind(f"{os.getuid() + 1}.sock") + self.bind(f"{os.getuid() + 1}.desktop.sock") + self.bind("notauid.sock") + self.assertEqual(bridge.session_socks("shell"), []) + self.assertEqual(bridge.session_socks("desktop"), []) + + def test_a_plain_file_is_not_a_socket(self): + Path(self.dir, f"{os.getuid()}.sock").touch() + self.assertEqual(bridge.session_socks("shell"), []) + + +@unittest.skipUnless(os.path.exists("/proc/net/tcp"), "needs the Linux socket table") +class TcpPeer(unittest.TestCase): + def test_finds_the_uid_of_a_loopback_client(self): + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen() + self.addCleanup(server.close) + client = socket.create_connection(server.getsockname()) + self.addCleanup(client.close) + conn, _ = server.accept() + self.addCleanup(conn.close) + self.assertEqual(bridge.tcp_peer_uid("127.0.0.1", client.getsockname()[1]), os.getuid()) + + def test_unknown_connection_is_unknown(self): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + self.assertIsNone(bridge.tcp_peer_uid("127.0.0.1", port)) + + def test_non_loopback_and_junk_are_unknown(self): + self.assertIsNone(bridge.tcp_peer_uid("8.8.8.8", 443)) + self.assertIsNone(bridge.tcp_peer_uid("testclient", 50000)) + + +class PeerGate(unittest.TestCase): + OWN = {"host": "localhost:8080", "origin": "http://localhost:8080"} + + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="tos-peer-") + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + env = mock.patch.dict(os.environ, {"TURINGOS_SESSION_DIR": self.dir}) + env.start() + self.addCleanup(env.stop) + os.environ.pop("TURINGOS_BRIDGE_PEER_CHECK", None) + os.environ.pop("TURINGOS_BRIDGE_ALLOW_UIDS", None) + + def ws(self): + return SimpleNamespace(headers=dict(self.OWN), client=SimpleNamespace(host="127.0.0.1", port=40000)) + + def login(self): + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.bind(str(Path(self.dir, f"{os.getuid()}.sock"))) + self.addCleanup(s.close) + + def test_same_user_gets_in(self): + self.login() + with mock.patch.object(bridge, "tcp_peer_uid", return_value=os.getuid()): + self.assertTrue(bridge.origin_allowed(self.ws())) + + def test_another_user_is_refused_despite_good_headers(self): + self.login() + with mock.patch.object(bridge, "tcp_peer_uid", return_value=os.getuid() + 1): + self.assertFalse(bridge.origin_allowed(self.ws())) + + def test_unidentifiable_peer_is_refused(self): + self.login() + with mock.patch.object(bridge, "tcp_peer_uid", return_value=None): + self.assertFalse(bridge.origin_allowed(self.ws())) + + def test_no_session_yet_has_nothing_to_match(self): + with mock.patch.object(bridge, "tcp_peer_uid", return_value=None): + self.assertTrue(bridge.peer_allowed(self.ws())) + + def test_check_can_be_switched_off(self): + self.login() + with mock.patch.dict(os.environ, {"TURINGOS_BRIDGE_PEER_CHECK": "0"}): + with mock.patch.object(bridge, "tcp_peer_uid", return_value=None): + self.assertTrue(bridge.peer_allowed(self.ws())) + + def test_bad_headers_still_refused(self): + ws = self.ws() + ws.headers["origin"] = "http://evil.example" + with mock.patch.dict(os.environ, {"TURINGOS_BRIDGE_PEER_CHECK": "0"}): + self.assertFalse(bridge.origin_allowed(ws)) + + +@unittest.skipUnless(hasattr(socket, "SO_PEERCRED"), "needs SO_PEERCRED") +class ShellHelperPeer(unittest.TestCase): + def test_unidentifiable_peer_may_not_run_commands(self): + async def go(): + d = tempfile.mkdtemp(prefix="tos-sh-") + path = os.path.join(d, "h.sock") + try: + with mock.patch.object(helper, "peer_uid", return_value=-1): + server = await asyncio.start_unix_server(helper.handle, path=path) + async with server: + reader, writer = await asyncio.open_unix_connection(path) + writer.write(b'{"command": "echo hi"}\n') + await writer.drain() + reply = json.loads((await reader.readline()).decode()) + writer.close() + return reply + finally: + shutil.rmtree(d, ignore_errors=True) + + reply = asyncio.run(go()) + self.assertEqual(reply["status"], "error") + self.assertIn("refusing", reply["errors"][0]) + + +class PlanLifecycle(unittest.TestCase): + def fresh(self): + store = planner.PlanStore() + return store, store.create("sess", "do a thing", []) + + def test_rejected_plan_stays_rejected(self): + async def go(): + store, plan = self.fresh() + self.assertEqual(store.reject(plan["plan_id"], "sess")["status"], "ok") + self.assertEqual(plan["status"], "rejected") + self.assertEqual(store.approve(plan["plan_id"], "sess")["status"], "error") + self.assertIsNone(store.get_approved_plan(plan["plan_id"])) + return (await planner.execute_plan(plan))["status"] + + self.assertEqual(asyncio.run(go()), "error") + + def test_approved_plan_runs_once(self): + async def go(): + store, plan = self.fresh() + self.assertEqual(store.approve(plan["plan_id"], "sess")["status"], "ok") + self.assertEqual(store.reject(plan["plan_id"], "sess")["status"], "error") + first = await planner.execute_plan(plan) + second = await planner.execute_plan(plan) + return first["status"], second["status"], plan["status"] + + self.assertEqual(asyncio.run(go()), ("ok", "error", "completed")) + + def test_unknown_plan(self): + store = planner.PlanStore() + self.assertEqual(store.reject("nope", "sess")["status"], "error") + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/web/js/plan-renderer.js b/ui/web/js/plan-renderer.js index 97dfd13..1ef0a90 100644 --- a/ui/web/js/plan-renderer.js +++ b/ui/web/js/plan-renderer.js @@ -111,7 +111,7 @@ function rejectPlan() { if (!currentPlan) return; - sendPlanAction("approve_plan", { plan_id: currentPlan.plan_id, session_id: currentPlan.session_id, rejected: true }); + sendPlanAction("reject_plan", { plan_id: currentPlan.plan_id, session_id: currentPlan.session_id }); resolvePlan(false); }