From 9ef8e0276b3f4e56844bd14e627faf8aeb9fc304 Mon Sep 17 00:00:00 2001 From: uncoalesced Date: Mon, 19 Oct 2026 18:52:16 +0530 Subject: [PATCH 1/5] fix(bazaar): fail closed on missing or invalid index signature Rust verifier and registry.sh now reject the remote index when the signature, public key or openssl is missing, instead of trusting it. Unverified remote data is no longer cached. Adds negative tests. --- bazaar/registry.sh | 36 +++++++++++++++++++++++------------- daemon/src/bazaar.rs | 38 +++++++++++++++++++++++--------------- tests/bazaar_fallback.sh | 16 ++++++++++++++++ 3 files changed, 62 insertions(+), 28 deletions(-) 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..069897a 100644 --- a/daemon/src/bazaar.rs +++ b/daemon/src/bazaar.rs @@ -79,19 +79,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 +118,7 @@ pub fn verify_signature(data: &[u8], sig: &[u8]) -> bool { match status { Ok(s) => s.success(), - Err(_) => true, + Err(_) => false, } } @@ -148,24 +152,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" + ); } } } diff --git a/tests/bazaar_fallback.sh b/tests/bazaar_fallback.sh index dca38c6..3354c32 100644 --- a/tests/bazaar_fallback.sh +++ b/tests/bazaar_fallback.sh @@ -263,4 +263,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!" From 5fb932a09023f0903cf560f90f4208133b05d52e Mon Sep 17 00:00:00 2001 From: uncoalesced Date: Mon, 19 Oct 2026 19:09:29 +0530 Subject: [PATCH 2/5] fix(bazaar): harden installer against registry-driven injection Validate tool keys and env_required names before indirect expansion, pin registered npx package args to the verified version, require sha256 for npm and archive entries (fail closed), and reject archives with unsafe member paths. --- bazaar/install.sh | 130 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 91 insertions(+), 39 deletions(-) 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:-}}" From 857270fba8531fbdd45f38aa107caab419291c24 Mon Sep 17 00:00:00 2001 From: uncoalesced Date: Wed, 21 Oct 2026 11:15:49 +0530 Subject: [PATCH 3/5] fix(trust): harden local access and make CI lint/format clean bridged: egress allowlist is server-side only; http(s) only, no redirects, no non-public addresses, response size cap. bridged-ws: connecting process must belong to a logged-in user (socket table lookup), helper sockets must be owned by the uid in their name, /shell no longer matches the desktop socket, replies read to newline. shell-helper: refuse peers it cannot identify. agentd-plan: add reject_plan, approve/execute only from the right state, plans run once. CI: rustfmt and shellcheck findings that were already failing on main. --- .github/workflows/ci.yml | 2 +- daemon/src/bazaar.rs | 7 +- daemon/src/control.rs | 8 +- tests/bazaar_fallback.sh | 6 +- tests/benchmark.sh | 7 + trust/daemons/agentd-plan.py | 49 ++++- trust/daemons/bridged-ws.py | 123 ++++++++++-- trust/daemons/bridged.py | 77 +++++++- trust/daemons/shell-helper.py | 5 +- trust/schemas/audit-entry.json | 2 +- trust/tests/test_bridge_desktop.py | 2 + trust/tests/test_bridge_shell.py | 4 + trust/tests/test_local_auth.py | 288 +++++++++++++++++++++++++++++ ui/web/js/plan-renderer.js | 2 +- 14 files changed, 544 insertions(+), 38 deletions(-) create mode 100644 trust/tests/test_local_auth.py 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/daemon/src/bazaar.rs b/daemon/src/bazaar.rs index 069897a..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 { @@ -388,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/tests/bazaar_fallback.sh b/tests/bazaar_fallback.sh index 3354c32..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" diff --git a/tests/benchmark.sh b/tests/benchmark.sh index 806e17b..909df25 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" @@ -114,6 +118,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 +139,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 +152,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/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); } From 0856df0e8714903bde458f565a2eabcbaf62222a Mon Sep 17 00:00:00 2001 From: uncoalesced Date: Wed, 21 Oct 2026 11:25:19 +0530 Subject: [PATCH 4/5] fix(ci): skip daemon cargo check in benchmark when ALSA dev libs are absent The shell job runs on a bare runner; the daemon job already builds and tests turingosd in the ISO's Debian container. --- tests/benchmark.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/benchmark.sh b/tests/benchmark.sh index 909df25..3f03d06 100644 --- a/tests/benchmark.sh +++ b/tests/benchmark.sh @@ -110,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 From 7248d8808c099c7e2d08cfe0bd55c4736723a4f4 Mon Sep 17 00:00:00 2001 From: uncoalesced Date: Tue, 27 Oct 2026 14:49:47 +0530 Subject: [PATCH 5/5] fix(session): kiosk no longer hangs on a black screen under Hyprland The shell probed the screen size with an untimed xdpyinfo. Under Hyprland DISPLAY is XWayland, which never started in a VMware VM, so xdpyinfo waited on its socket forever and the UI never appeared. - gfx::screen_size asks hyprctl under Wayland and falls back to xdpyinfo; every probe has a 3s timeout. kiosk and gfx::screen_pixels share it. - Brave runs on Wayland ozone when WAYLAND_DISPLAY is set, X11 otherwise. - On Wayland Brave ignores --class (app id brave-127.0.0.1__-Default), so the Hyprland shell rule and turingos-omni's Super+Space match that too. - tests/kiosk_display.sh covers hanging probes, ozone choice, the rule and Super+Space with stubbed hyprctl/xdpyinfo. --- .../etc/turingos/hyprland.conf | 9 +- session/turingos-gfx-detect | 30 ++++-- session/turingos-kiosk | 17 +-- session/turingos-omni | 11 +- tests/kiosk_display.sh | 101 ++++++++++++++++++ 5 files changed, 147 insertions(+), 21 deletions(-) create mode 100755 tests/kiosk_display.sh 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/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"