diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b650617c..d66a8479 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,6 +223,104 @@ jobs: with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_name }} + + bundle: + needs: [build, resolve] + if: ${{ !cancelled() && needs.build.result == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve.outputs.source_ref }} + - uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: 'codewhale*' + - name: Create platform archives + shell: bash + run: | + set -euo pipefail + mkdir -p bundles/checksums + MANIFEST="bundles/checksums/codewhale-bundles-sha256.txt" + : > "$MANIFEST" + + bundle() { + local platform="$1" # linux-x64, linux-arm64, macos-x64, macos-arm64, windows-x64 + local cli_src="$2" # artifact name for codewhale binary + local tui_src="$3" # artifact name for codewhale-tui binary + local ext="$4" # tar.gz or zip + local variant="$5" # '' (standard) or 'portable' (Windows only, no install script) + shift 5 + + local dir="bundles/codewhale-${platform}${variant:+-}${variant}" + mkdir -p "$dir" + + # Copy binaries, stripping platform suffixes + local cli_dst="codewhale" + local tui_dst="codewhale-tui" + if [[ "$platform" == windows-* ]]; then + cli_dst="codewhale.exe" + tui_dst="codewhale-tui.exe" + fi + cp "artifacts/${cli_src}/${cli_src}" "$dir/${cli_dst}" + cp "artifacts/${tui_src}/${tui_src}" "$dir/${tui_dst}" + + # Add install script (standard variant only) + if [[ "$variant" != "portable" ]]; then + if [[ "$platform" == windows-* ]]; then + cp scripts/release/install.bat "$dir/" + # Convert line endings to CRLF for Windows + sed -i 's/$/\r/' "$dir/install.bat" 2>/dev/null || true + else + cp scripts/release/install.sh "$dir/" + chmod +x "$dir/install.sh" + fi + fi + + if [[ "$ext" == "zip" ]]; then + (cd bundles && zip -r "codewhale-${platform}${variant:+-}${variant}.zip" "codewhale-${platform}${variant:+-}${variant}/") + else + tar -czf "bundles/codewhale-${platform}${variant:+-}${variant}.tar.gz" -C bundles "codewhale-${platform}${variant:+-}${variant}/" + fi + + local archive="codewhale-${platform}${variant:+-}${variant}.${ext}" + sha256sum "bundles/${archive}" | awk '{printf "%s %s\n", $1, $2}' >> "$MANIFEST" + echo " Created bundles/${archive}" + } + + # Platform: linux-x64 + bundle linux-x64 \ + codewhale-linux-x64 codewhale-tui-linux-x64 tar.gz "" + + # Platform: linux-arm64 + bundle linux-arm64 \ + codewhale-linux-arm64 codewhale-tui-linux-arm64 tar.gz "" + + # Platform: macos-x64 + bundle macos-x64 \ + codewhale-macos-x64 codewhale-tui-macos-x64 tar.gz "" + + # Platform: macos-arm64 + bundle macos-arm64 \ + codewhale-macos-arm64 codewhale-tui-macos-arm64 tar.gz "" + + # Platform: windows-x64 (standard + portable) + bundle windows-x64 \ + codewhale-windows-x64.exe codewhale-tui-windows-x64.exe zip "" + bundle windows-x64 \ + codewhale-windows-x64.exe codewhale-tui-windows-x64.exe zip "portable" + + echo "" + echo "=== Archive checksums ===" + cat "$MANIFEST" + + - name: Upload bundle artifacts + uses: actions/upload-artifact@v4 + with: + name: codewhale-bundles + path: bundles/* + if-no-files-found: error + docker: needs: [build, resolve] if: ${{ !cancelled() && needs.build.result == 'success' }} @@ -292,8 +390,8 @@ jobs: cache-to: type=gha,mode=max release: - needs: [build, docker, resolve] - if: ${{ !cancelled() && needs.build.result == 'success' && needs.docker.result == 'success' }} + needs: [build, bundle, docker, resolve] + if: ${{ !cancelled() && needs.build.result == 'success' && needs.bundle.result == 'success' && needs.docker.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write @@ -365,35 +463,52 @@ jobs: Both crates are required — `codewhale-cli` produces the `codewhale` dispatcher and `codewhale-tui` produces the interactive runtime that the dispatcher delegates to. Installing only one binary will fail at runtime with a `MISSING_COMPANION_BINARY` error. - ### Manual download + ### Manual download — platform archives (recommended) - **Both** binaries below must be downloaded for your platform and dropped into the same directory (e.g. `~/.local/bin/`): + Each archive below contains **both** the `codewhale` dispatcher and `codewhale-tui` runtime, plus an install script: - | Platform | Dispatcher | TUI runtime | + | Platform | Archive | Install script | |---|---|---| - | Linux x64 | `codewhale-linux-x64` | `codewhale-tui-linux-x64` | - | Linux ARM64 | `codewhale-linux-arm64` | `codewhale-tui-linux-arm64` | - | macOS x64 | `codewhale-macos-x64` | `codewhale-tui-macos-x64` | - | macOS ARM | `codewhale-macos-arm64` | `codewhale-tui-macos-arm64` | - | Windows x64 | `codewhale-windows-x64.exe` | `codewhale-tui-windows-x64.exe` | + | Linux x64 | `codewhale-linux-x64.tar.gz` | `install.sh` | + | Linux ARM64 | `codewhale-linux-arm64.tar.gz` | `install.sh` | + | macOS x64 | `codewhale-macos-x64.tar.gz` | `install.sh` | + | macOS ARM | `codewhale-macos-arm64.tar.gz` | `install.sh` | + | Windows x64 | `codewhale-windows-x64.zip` | `install.bat` | + | Windows x64 (portable) | `codewhale-windows-x64-portable.zip` | — | - Then `chmod +x` both (Unix) and run `./codewhale`. + **Unix (Linux / macOS):** + ```bash + tar xzf codewhale-.tar.gz + cd codewhale- + ./install.sh + ``` - Legacy `deepseek-*` and `deepseek-tui-*` assets are also attached for one release cycle so that existing `deepseek update` invocations on v0.8.40 keep working; they install the deprecation shims, which forward to the canonical binaries. + **Windows:** + - Extract `codewhale-windows-x64.zip` + - Run `install.bat` (copies to `%USERPROFILE%\bin`) + - Add `%USERPROFILE%\bin` to your PATH + + The **portable** Windows archive skips the install script — extract and run from any directory. + + Individual binaries are also attached below for scripting and the npm wrapper. Legacy `deepseek-*` and `deepseek-tui-*` assets ship for one release cycle so that existing `deepseek update` invocations on v0.8.40 keep working; they install the deprecation shims, which forward to the canonical binaries. ### Verify (recommended) - Download `codewhale-artifacts-sha256.txt` from this Release and verify: + Download the checksum manifests from this Release and verify: ```bash - # Linux + # Linux — archive bundles + sha256sum -c codewhale-bundles-sha256.txt + + # Linux — individual binaries sha256sum -c codewhale-artifacts-sha256.txt # macOS + shasum -a 256 -c codewhale-bundles-sha256.txt shasum -a 256 -c codewhale-artifacts-sha256.txt ``` - The legacy `deepseek-artifacts-sha256.txt` is also attached for backward compatibility and contains the same hashes. + The legacy `deepseek-artifacts-sha256.txt` is also attached for backward compatibility and contains the same hashes as the canonical manifest. ## Changelog diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8fee06..4d42844c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.46] - 2026-05-26 + ### Added - **`CODEWHALE_*` env aliases.** `CODEWHALE_PROVIDER`, `CODEWHALE_MODEL`, and `CODEWHALE_BASE_URL` are public product-scoped aliases that take precedence over the legacy `DEEPSEEK_*` forms. The `DEEPSEEK_*` names - remain accepted for back-compat. Recommended setup paths are - `codewhale --provider `, `provider = ""` in - `~/.codewhale/config.toml`, or `CODEWHALE_PROVIDER=`. + remain accepted for back-compat. +- **Platform archive bundles.** Release artifacts now ship as per-platform + archives (`tar.gz` for Linux/macOS, `.zip` for Windows) containing both + `codewhale` and `codewhale-tui` binaries plus an install script. No more + downloading two loose files and guessing which ones to pick (#2193). +- **Windows portable archive.** `codewhale-windows-x64-portable.zip` ships + the two binaries without an install script for USB-stick distribution + (#2193). +- **Web install download tile.** The website install page now shows a + platform-aware download tile with arch detection, SHA256 checksum + display, and China mirror links, instead of burying the download behind + the Cargo instructions (#2192). +- **Whale dark palette refresh.** Better contrast and layer separation + across the TUI color scheme (#2197). +- **Auto-collapse finished sub-agents.** Completed sub-agent sessions now + collapse automatically in the sidebar, reducing noise during long + sessions (#2195). +- **Shell-running status chip.** A `⏳ shell running` chip appears in the + TUI footer while background shell tasks are active (#2194). +- **Sandbox process hardening (Linux).** `PR_SET_DUMPABLE=0`, + `NO_NEW_PRIVS`, and `RLIMIT_CORE=0` are applied at shell startup to + harden child processes against inspection and privilege escalation + (#2183). +- **CONTRIBUTING.md cross-links.** Issue and PR templates are now + cross-linked from CONTRIBUTING.md to improve contributor onboarding + (#2203). ### Changed -- **DeepSeek-first focus.** v0.8.45.x refocuses on delivering the - highest-quality experience on DeepSeek first. The project's broader - goal remains to become a strong harness for open-source and open-weight - coding models, but additional first-class provider paths are planned - for v0.9.0 after the core DeepSeek workflow is solid. +- **DeepSeek-first focus.** v0.8.46 refocuses on delivering the + highest-quality experience on DeepSeek first. Additional first-class + provider paths are planned for v0.9.0 after the core DeepSeek workflow + is solid. + +### Fixed + +- **Model name casing preserved.** `normalize_model_name_for_provider` no + longer lowercases user-set model names such as `DeepSeek-V4-Flash`, + preventing API lookup failures on case-sensitive backends (#2109). +- **Esc in model picker applies selection.** Dismissing the model picker + with Esc now applies the last-highlighted choice instead of reverting + (#2196). +- **Web install downloads both binaries.** The `install-binary.tsx` + snippet now fetches both `codewhale` and `codewhale-tui`, fixing the + `MISSING_COMPANION_BINARY` trap on fresh npm installs (#2191). +- **`grep_files` skips large directories.** The pure-Rust search tool + now skips known-large directories (`.git`, `node_modules`, `target`) + before walking, preventing hangs on deep or slow filesystems. +- **Version-update hint uses semver.** The update notification in the + footer now compares versions semantically instead of lexicographically, + so `0.8.10 > 0.8.9` is recognized correctly. +- **CVE-2026-8723 in feishu-bridge.** Bumped `qs` to `>=6.15.2` in the + Feishu bridge integration (#2198). ## [0.8.45] - 2026-05-25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 759b6d44..1cbc15b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,6 +184,9 @@ these crates, including the bottom-up build order. ## Pull Request Guidelines +- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) when opening + a PR — it includes the Summary, Testing, and Checklist sections reviewers + expect - Keep PRs focused on a single change - Update documentation if needed - Add tests for new functionality @@ -217,7 +220,14 @@ cargo check ## Reporting Issues -When reporting issues, please include: +When reporting issues, please use one of the issue templates: + +- [Bug report](.github/ISSUE_TEMPLATE/bug_report.md) — for reproducible problems + or regressions +- [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) — for ideas and + improvements + +Issue reports should include: - Operating system and version - Rust version (`rustc --version`) @@ -226,9 +236,17 @@ When reporting issues, please include: - Expected vs actual behavior - Relevant error messages or logs +## Security + +If you discover a security vulnerability, please do **not** open a public issue. +See [SECURITY.md](SECURITY.md) for the responsible disclosure process and +contact information. + ## Code of Conduct -Be respectful and inclusive. We welcome contributors of all backgrounds and experience levels. +Be respectful and inclusive. We welcome contributors of all backgrounds and +experience levels. See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for the full +code of conduct. ## License diff --git a/config.example.toml b/config.example.toml index 87af1a8e..73c211f2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -131,6 +131,21 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc # The backend uses a 30-second HTTP timeout. Background, interactive, and # TTY modes are not supported with external backends — all commands run # synchronously via HTTP. +# ───────────────────────────────────────────────────────────────────────────────── +# Bubblewrap (Linux only, additional filesystem isolation) +# ───────────────────────────────────────────────────────────────────────────────── +# When set to true and `/usr/bin/bwrap` is present, exec_shell commands are +# routed through bubblewrap instead of relying solely on Landlock. Bubblewrap +# creates a read-only view of the root filesystem with write access limited to +# the working directory. Install separately: +# +# Ubuntu/Debian: apt install bubblewrap +# Fedora: dnf install bubblewrap +# Arch: pacman -S bubblewrap +# +# prefer_bwrap = false # default — use Landlock only +# +# Env override: DEEPSEEK_PREFER_BWRAP=true # auto_allow entries match by command prefix, not raw string. # See command_safety.rs for the prefix dictionary. diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index 9bc2a945..83248e33 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -163,7 +163,7 @@ mod tests { assert!(matches!( result.action, Some(AppAction::SendMessage(content)) - if content == "Implement login flow".to_string() + if content == *"Implement login flow" )); } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index a51a3c8b..883cf8ed 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -411,12 +411,24 @@ fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> { /// aliases are valid for some compatible backends, but sending them to /// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for /// config/back-compat, and canonicalize only when the active provider is known. +/// +/// Preserves the caller's casing when the model is already a recognised +/// DeepSeek id (e.g. `DeepSeek-V4-Flash` stays as-is). Only rewrites compact +/// aliases like `deepseek-v4pro` → `deepseek-v4-pro`. #[must_use] pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option { let normalized = normalize_model_name(model)?; if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) && let Some(canonical) = canonical_official_deepseek_model_id(&normalized) { + // When the user's input already matches a known model id + // case-insensitively, keep their original casing; only rewrite + // compact aliases (e.g. v4pro → v4-pro). + if canonical.eq_ignore_ascii_case(&normalized) + || normalized.to_ascii_lowercase() == canonical + { + return Some(normalized); + } return Some(canonical.to_string()); } if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) { @@ -970,6 +982,11 @@ pub struct Config { pub sandbox_url: Option, /// Optional API key for the external sandbox backend (sent as Bearer token). pub sandbox_api_key: Option, + /// When true and `/usr/bin/bwrap` is present on Linux, route exec_shell + /// through bubblewrap instead of relying solely on Landlock (#2184). + /// Defaults to false. Requires the `bubblewrap` package to be installed + /// separately — we do NOT vendor bwrap. + pub prefer_bwrap: Option, pub managed_config_path: Option, pub requirements_path: Option, pub max_subagents: Option, @@ -2934,8 +2951,7 @@ fn auth_mode_uses_kimi_oauth(mode: &str) -> bool { fn normalize_auth_mode(mode: &str) -> String { mode.trim() .to_ascii_lowercase() - .replace('-', "_") - .replace(' ', "_") + .replace(['-', ' '], "_") } fn base_url_uses_local_host(base_url: &str) -> bool { @@ -3071,6 +3087,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend), sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url), sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key), + prefer_bwrap: override_cfg.prefer_bwrap.or(base.prefer_bwrap), managed_config_path: override_cfg .managed_config_path .or(base.managed_config_path), diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f98f523c..1537d87d 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -171,6 +171,10 @@ pub struct EngineConfig { /// once at engine construction, then threaded onto every /// `SubAgentRuntime` the engine builds (#1806, #1808). pub subagent_api_timeout: Duration, + /// When true and `/usr/bin/bwrap` is present on Linux, route exec_shell + /// through bubblewrap instead of relying solely on Landlock (#2184). + #[allow(dead_code)] // Wired through ShellManager in follow-up PR + pub prefer_bwrap: bool, } impl Default for EngineConfig { @@ -214,6 +218,7 @@ impl Default for EngineConfig { subagent_api_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS, ), + prefer_bwrap: false, } } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index be286978..252ea9f5 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -732,6 +732,11 @@ enum SandboxCommand { async fn main() -> Result<()> { configure_windows_console_utf8(); + // ── Process hardening (#2183) ───────────────────────────────────────── + // MUST run before Tokio is booted and before any threads are spawned. + // See crates/tui/src/sandbox/process_hardening.rs for ordering rationale. + crate::sandbox::process_hardening::apply_process_hardening(); + // Set up process panic hook before anything else — writes crash dumps // to ~/.deepseek/crashes/ even if the panic happens before tokio is up, // and restores the terminal so a panicked TUI doesn't leave the user's @@ -5138,6 +5143,7 @@ async fn run_exec_agent( runtime_services: crate::tools::spec::RuntimeToolServices::default(), subagent_model_overrides: config.subagent_model_overrides(), subagent_api_timeout: std::time::Duration::from_secs(config.subagent_api_timeout_secs()), + prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), vision_config: config.vision_model_config(), diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index 2890804c..74d72e06 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -4,17 +4,17 @@ use ratatui::style::Color; #[cfg(target_os = "macos")] use std::process::Command; -// v0.8.45 Whale dark palette — refreshed ocean/navy identity. -pub const WHALE_BG_RGB: (u8, u8, u8) = (13, 21, 37); // #0D1525 Deep Navy -pub const WHALE_PANEL_RGB: (u8, u8, u8) = (19, 29, 48); // #131D30 -pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (26, 40, 64); // #1A2840 -pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (30, 50, 82); // #1E3252 +// v0.8.46 Whale dark palette — improved contrast and layer separation. +pub const WHALE_BG_RGB: (u8, u8, u8) = (10, 17, 32); // #0A1120 Deep Navy +pub const WHALE_PANEL_RGB: (u8, u8, u8) = (22, 34, 56); // #162238 +pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (36, 52, 78); // #24344E +pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (48, 68, 100); // #304464 pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); // #F6F2E8 Whale Ivory pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 224, 234); // #D9E0EA pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (169, 180, 199); // #A9B4C7 Mist Gray -pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (122, 134, 158); // #7A869E +pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 150, 174); // #8A96AE #[allow(dead_code)] -pub const WHALE_TEXT_DIM_RGB: (u8, u8, u8) = (107, 120, 146); // #6B7892 +pub const WHALE_TEXT_DIM_RGB: (u8, u8, u8) = (118, 130, 156); // #76829C pub const WHALE_ACCENT_PRIMARY_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold pub const WHALE_ACCENT_SECONDARY_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam pub const WHALE_ACCENT_ACTION_RGB: (u8, u8, u8) = (255, 122, 89); // #FF7A59 Coral Spark @@ -26,10 +26,10 @@ pub const WHALE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 214, 222); // #FFD6DE Error pub const WHALE_WARNING_RGB: (u8, u8, u8) = (240, 160, 48); // #F0A030 pub const WHALE_SUCCESS_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam pub const WHALE_INFO_RGB: (u8, u8, u8) = (106, 174, 242); // #6AAEF2 Sky -pub const WHALE_BORDER_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F +pub const WHALE_BORDER_RGB: (u8, u8, u8) = (52, 88, 145); // #345891 pub const WHALE_REASONING_TEXT_RGB: (u8, u8, u8) = (224, 153, 72); // #E09948 pub const WHALE_REASONING_SURFACE_RGB: (u8, u8, u8) = (42, 34, 24); // #2A2218 -pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (20, 30, 42); // #141E2A +pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (24, 36, 52); // #182434 pub const WHALE_DIFF_ADDED_RGB: (u8, u8, u8) = (87, 199, 133); // #57C785 #[allow(dead_code)] pub const WHALE_DIFF_DELETED_RGB: (u8, u8, u8) = (255, 92, 122); // #FF5C7A Rose Red @@ -39,11 +39,11 @@ pub const WHALE_MODE_AGENT_RGB: (u8, u8, u8) = (80, 150, 255); // #5096FF pub const WHALE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 100, 100); // #FF6464 pub const WHALE_MODE_PLAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold pub const WHALE_MODE_GOAL_RGB: (u8, u8, u8) = (100, 220, 160); // #64DCA0 -pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = (133, 184, 234); // #85B8EA -pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = (192, 143, 153); // #C08F99 +pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = (140, 190, 238); // #8CBEEE +pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = (198, 150, 160); // #C696A0 pub const WHALE_TOOL_OUTPUT_RGB: (u8, u8, u8) = (194, 208, 224); // #C2D0E0 -pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (24, 34, 53); // #182235 -pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (31, 45, 69); // #1F2D45 +pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (28, 40, 62); // #1C283E +pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (38, 54, 80); // #263650 // Backward-compatible aliases for existing call sites. pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = WHALE_ACCENT_PRIMARY_RGB; @@ -2060,4 +2060,4 @@ mod tests { let _ = ColorDepth::detect(); let _ = adapt_color(DEEPSEEK_INK, ColorDepth::detect()); } -} +} \ No newline at end of file diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 55be26e8..c2fca300 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1976,6 +1976,7 @@ impl RuntimeThreadManager { subagent_api_timeout: std::time::Duration::from_secs( self.config.subagent_api_timeout_secs(), ), + prefer_bwrap: self.config.prefer_bwrap.unwrap_or(false), memory_enabled: self.config.memory_enabled(), memory_path: self.config.memory_path(), vision_config: self.config.vision_model_config(), diff --git a/crates/tui/src/sandbox/bwrap.rs b/crates/tui/src/sandbox/bwrap.rs new file mode 100644 index 00000000..4316f08e --- /dev/null +++ b/crates/tui/src/sandbox/bwrap.rs @@ -0,0 +1,131 @@ +//! Bubblewrap (bwrap) passthrough for Linux sandbox (#2184). +//! +//! Bubblewrap is a setuid-less container runtime used by Flatpak and other +//! projects. It creates a new mount namespace with configurable bind mounts, +//! providing filesystem isolation without requiring root privileges. +//! +//! # How it works +//! +//! When `/usr/bin/bwrap` is present AND the config key `[sandbox] prefer_bwrap` +//! is set to `true`, exec_shell commands are routed through bwrap instead of +//! relying solely on Landlock. The bwrap invocation looks like: +//! +//! ```text +//! bwrap \ +//! --ro-bind / / \ +//! --bind \ +//! --chdir \ +//! --unshare-all \ +//! -- +//! ``` +//! +//! This creates a read-only view of the entire filesystem with write access +//! limited to the working directory. +//! +//! # Important +//! +//! We do NOT vendor bwrap. The user must install it themselves: +//! +//! - Ubuntu/Debian: `apt install bubblewrap` +//! - Fedora: `dnf install bubblewrap` +//! - Arch: `pacman -S bubblewrap` +//! +//! If bwrap is not installed, we fall back to Landlock. + +use std::path::PathBuf; + +/// Canonical path to the bubblewrap binary. +#[cfg(target_os = "linux")] +pub const BWRAP_PATH: &str = "/usr/bin/bwrap"; + +/// Check if bubblewrap is installed and executable. +#[cfg(target_os = "linux")] +pub fn is_available() -> bool { + std::path::Path::new(BWRAP_PATH).exists() +} + +#[cfg(not(target_os = "linux"))] +pub fn is_available() -> bool { + false +} + +/// Build a bwrap command that wraps the given program and arguments. +/// +/// The returned command vector is suitable for use as `ExecEnv.command` — +/// it replaces the normal program+args with a bwrap invocation that sets +/// up a read-only root filesystem with write access only to the specified +/// working directory. +/// +/// # Arguments +/// +/// - `cwd` — working directory that gets writable bind-mount +/// - `program` — the program to run inside the container +/// - `args` — arguments to pass to the program +/// +/// # Returns +/// +/// A `Vec` representing the full bwrap invocation. +#[cfg(target_os = "linux")] +pub fn build_bwrap_command(cwd: &std::path::Path, program: &str, args: &[String]) -> Vec { + let mut cmd: Vec = Vec::with_capacity(10 + args.len()); + + cmd.push(BWRAP_PATH.to_string()); + + // Read-only bind-mount the entire root filesystem. + cmd.push("--ro-bind".to_string()); + cmd.push("/".to_string()); + cmd.push("/".to_string()); + + // Bind-mount the working directory with read-write access. + let cwd_str = cwd.to_string_lossy().to_string(); + cmd.push("--bind".to_string()); + cmd.push(cwd_str.clone()); + cmd.push(cwd_str.clone()); + + // Change to the working directory inside the container. + cmd.push("--chdir".to_string()); + cmd.push(cwd_str); + + // Unshare all namespaces for maximum isolation. + cmd.push("--unshare-all".to_string()); + + // Separator between bwrap args and the command to run. + cmd.push("--".to_string()); + + // The actual program and its arguments. + cmd.push(program.to_string()); + cmd.extend(args.iter().cloned()); + + cmd +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_available_does_not_panic() { + let _ = is_available(); + } + + #[test] + #[cfg(target_os = "linux")] + fn test_build_bwrap_command_structure() { + let cwd = std::path::Path::new("/home/user/project"); + let cmd = build_bwrap_command(cwd, "sh", &["-c".to_string(), "echo hi".to_string()]); + + // Should start with bwrap + assert_eq!(cmd[0], "/usr/bin/bwrap"); + + // Should have ro-bind for root + assert!(cmd.contains(&"--ro-bind".to_string())); + + // Should have --chdir + assert!(cmd.contains(&"--chdir".to_string())); + + // Should end with the command + assert_eq!(cmd[cmd.len() - 1], "echo hi"); + assert_eq!(cmd[cmd.len() - 2], "-c"); + assert_eq!(cmd[cmd.len() - 3], "sh"); + } +} diff --git a/crates/tui/src/sandbox/landlock.rs b/crates/tui/src/sandbox/landlock.rs index 7670d65b..4a083ea3 100644 --- a/crates/tui/src/sandbox/landlock.rs +++ b/crates/tui/src/sandbox/landlock.rs @@ -290,18 +290,32 @@ pub fn create_landlock_wrapper( cmd } -/// Detect if a failure was caused by Landlock denial +/// Detect if a failure was caused by Landlock or seccomp denial. +/// +/// Checks both Landlock-specific patterns (EACCES/EPERM) and seccomp-specific +/// patterns (Bad system call / SIGSYS). Seccomp violations are reported through +/// the same `was_denied` path so callers don't need to distinguish which layer +/// blocked the operation. #[cfg(target_os = "linux")] pub fn detect_denial(exit_code: i32, stderr: &str) -> bool { if exit_code == 0 { return false; } - // Landlock denials typically result in EACCES or EPERM - stderr.contains("Permission denied") + // Landlock denials typically result in EACCES or EPERM. + let landlock_denial = stderr.contains("Permission denied") || stderr.contains("Operation not permitted") || stderr.contains("EACCES") - || stderr.contains("EPERM") + || stderr.contains("EPERM"); + + // Seccomp denials (#2182): SIGSYS (exit code 31 or "Bad system call"). + let seccomp_denial = exit_code == 31 + || stderr.contains("Bad system call") + || stderr.contains("bad system call") + || stderr.contains("SIGSYS") + || stderr.contains("seccomp"); + + landlock_denial || seccomp_denial } // Stub implementations for non-Linux platforms diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index 508e3bd6..d52d9ffc 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -30,6 +30,7 @@ pub mod backend; pub mod opensandbox; pub mod policy; +pub mod process_hardening; #[cfg(target_os = "macos")] pub mod seatbelt; @@ -37,6 +38,12 @@ pub mod seatbelt; #[cfg(target_os = "linux")] pub mod landlock; +#[cfg(target_os = "linux")] +pub mod seccomp; + +#[cfg(target_os = "linux")] +pub mod bwrap; + #[cfg(target_os = "windows")] pub mod windows; @@ -296,17 +303,34 @@ pub struct SandboxManager { /// Force a specific sandbox type (for testing). #[allow(dead_code)] forced_sandbox: Option, + + /// When true and bwrap is available on Linux, route commands through + /// bubblewrap instead of Landlock alone (#2184). + prefer_bwrap: bool, } impl SandboxManager { /// Create a new `SandboxManager`. pub fn new() -> Self { + Self::default() + } + + /// Create a new `SandboxManager` with bwrap preference (#2184). + /// + /// When `prefer_bwrap` is true and `/usr/bin/bwrap` is present on Linux, + /// exec_shell commands will be routed through bubblewrap. + pub fn with_bwrap_preference(prefer_bwrap: bool) -> Self { Self { - sandbox_available: None, - forced_sandbox: None, + prefer_bwrap, + ..Self::default() } } + /// Set the bwrap preference (#2184). + pub fn set_prefer_bwrap(&mut self, prefer: bool) { + self.prefer_bwrap = prefer; + } + /// Check if sandboxing is available. pub fn is_available(&mut self) -> bool { if let Some(available) = self.sandbox_available { @@ -349,7 +373,7 @@ impl SandboxManager { SandboxType::MacosSeatbelt => Self::prepare_seatbelt(spec), #[cfg(target_os = "linux")] - SandboxType::LinuxLandlock => Self::prepare_landlock(spec), + SandboxType::LinuxLandlock => self.prepare_landlock(spec), #[cfg(target_os = "windows")] SandboxType::Windows => Self::prepare_windows(spec), @@ -402,26 +426,39 @@ impl SandboxManager { /// Prepare a Landlock-sandboxed execution environment (Linux). /// - /// Note: Landlock restricts the current process, so for subprocess sandboxing - /// we would need a helper binary. For now, this prepares the environment with - /// appropriate markers but doesn't actually apply Landlock (would need helper). + /// If `prefer_bwrap` is set and `/usr/bin/bwrap` is available, routes the + /// command through bubblewrap for stronger filesystem isolation (#2184). + /// Otherwise falls back to Landlock markers. #[cfg(target_os = "linux")] - fn prepare_landlock(spec: &CommandSpec) -> ExecEnv { - // Build the original command + fn prepare_landlock(&self, spec: &CommandSpec) -> ExecEnv { + // Check if bwrap passthrough should be used (#2184). + if self.prefer_bwrap && bwrap::is_available() { + let command = bwrap::build_bwrap_command( + &spec.cwd, + &spec.program, + &spec.args, + ); + + let mut env = spec.env.clone(); + env.insert("DEEPSEEK_SANDBOX".to_string(), "bwrap".to_string()); + + return ExecEnv { + command, + cwd: spec.cwd.clone(), + env, + timeout: spec.timeout, + sandbox_type: SandboxType::LinuxLandlock, + policy: spec.sandbox_policy.clone(), + }; + } + + // Fall back to Landlock (marker only — full implementation needs a helper). let mut command = vec![spec.program.clone()]; command.extend(spec.args.clone()); - // Add sandbox indicator to environment let mut env = spec.env.clone(); env.insert("DEEPSEEK_SANDBOX".to_string(), "landlock".to_string()); - // Note: Full Landlock implementation would use a helper binary that: - // 1. Sets up the Landlock ruleset based on policy - // 2. Applies restrictions to itself - // 3. Execs the target command - // - // For now, we just mark that Landlock would be used - ExecEnv { command, cwd: spec.cwd.clone(), @@ -509,7 +546,15 @@ impl SandboxManager { #[cfg(target_os = "linux")] SandboxType::LinuxLandlock => { - if stderr.contains("Permission denied") { + // Seccomp patterns checked first because they are more specific (#2182). + if stderr.contains("Bad system call") + || stderr.contains("bad system call") + || stderr.contains("SIGSYS") + || stderr.contains("seccomp") + { + "Seccomp blocked a disallowed system call (e.g., ptrace, mount, kexec)." + .to_string() + } else if stderr.contains("Permission denied") { "Landlock blocked access. The command tried to access a restricted path." .to_string() } else { @@ -694,4 +739,108 @@ mod tests { #[cfg(target_os = "macos")] assert_eq!(format!("{}", SandboxType::MacosSeatbelt), "macos-seatbelt"); } + + // ── Parity tests (#2187) ────────────────────────────────────────────── + + #[test] + fn test_parity_platform_sandbox_detection() { + let sandbox_type = get_platform_sandbox(); + let available = is_sandbox_available(); + if available { + assert!(sandbox_type.is_some()); + } + } + + #[test] + #[cfg(target_os = "macos")] + fn test_parity_macos_seatbelt_available() { + let st = get_platform_sandbox(); + assert!(matches!(st, Some(SandboxType::MacosSeatbelt))); + } + + #[test] + #[cfg(target_os = "linux")] + fn test_parity_linux_landlock_available() { + let st = get_platform_sandbox(); + assert!(matches!(st, Some(SandboxType::LinuxLandlock))); + } + + #[test] + fn test_parity_denial_zero_exit_never_denied() { + assert!(!SandboxManager::was_denied(SandboxType::None, 0, "anything")); + #[cfg(target_os = "macos")] + assert!(!SandboxManager::was_denied(SandboxType::MacosSeatbelt, 0, "")); + #[cfg(target_os = "linux")] + assert!(!SandboxManager::was_denied(SandboxType::LinuxLandlock, 0, "")); + #[cfg(target_os = "windows")] + assert!(!SandboxManager::was_denied(SandboxType::Windows, 0, "")); + } + + #[test] + #[cfg(target_os = "linux")] + fn test_parity_seccomp_sigsys_detected() { + assert!(SandboxManager::was_denied(SandboxType::LinuxLandlock, 31, "")); + assert!(SandboxManager::was_denied( + SandboxType::LinuxLandlock, 1, "Bad system call" + )); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_parity_seatbelt_file_write_detected() { + // Seatbelt patterns use "Sandbox: denied " format. + assert!(SandboxManager::was_denied( + SandboxType::MacosSeatbelt, 1, "Sandbox: ls denied file-write*" + )); + assert!(SandboxManager::was_denied( + SandboxType::MacosSeatbelt, 1, "Operation not permitted" + )); + } + + #[test] + fn test_parity_manager_default_no_bwrap() { + let manager = SandboxManager::default(); + let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5)) + .with_policy(SandboxPolicy::default()); + let env = manager.prepare(&spec); + #[cfg(target_os = "linux")] + { + let marker = env.env.get("DEEPSEEK_SANDBOX"); + assert!(marker.map_or(true, |v| v != "bwrap")); + } + let _ = env; + } + + #[test] + fn test_parity_manager_with_bwrap() { + let manager = SandboxManager::with_bwrap_preference(true); + let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5)) + .with_policy(SandboxPolicy::default()); + let env = manager.prepare(&spec); + #[cfg(target_os = "linux")] + { + if crate::sandbox::bwrap::is_available() { + let marker = env.env.get("DEEPSEEK_SANDBOX"); + assert_eq!(marker.map(String::as_str), Some("bwrap")); + } + } + let _ = env; + } + + #[test] + fn test_parity_exec_env_for_all_policies() { + let manager = SandboxManager::new(); + let policies = [ + SandboxPolicy::DangerFullAccess, + SandboxPolicy::ReadOnly, + SandboxPolicy::workspace_with_network(), + SandboxPolicy::default(), + ]; + for policy in &policies { + let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5)) + .with_policy(policy.clone()); + let env = manager.prepare(&spec); + assert_eq!(env.policy, *policy); + } + } } diff --git a/crates/tui/src/sandbox/policy.rs b/crates/tui/src/sandbox/policy.rs index 1ea5dc55..e67a7194 100644 --- a/crates/tui/src/sandbox/policy.rs +++ b/crates/tui/src/sandbox/policy.rs @@ -7,8 +7,12 @@ //! tightly controlled workspace-only write access. use serde::{Deserialize, Serialize}; +use std::io; use std::path::{Path, PathBuf}; +use crate::command_safety::SafetyLevel; +use super::{CommandSpec, ExecEnv}; + /// Determines execution restrictions for shell commands. /// /// The sandbox policy controls filesystem access, network access, and other @@ -256,6 +260,57 @@ impl WritableRoot { } } +/// Unified trait for platform-specific sandbox executors (#2186). +/// +/// Each platform module (seatbelt, landlock, windows) provides an +/// implementation of this trait. The `SandboxManager` dispatches through +/// the trait instead of calling platform-specific functions directly. +pub trait SandboxExecutor { + /// Prepare a sandboxed execution environment from a command spec. + /// + /// Returns the transformed command, environment, and sandbox metadata + /// needed to spawn the process. + fn prepare(&self, spec: &CommandSpec) -> io::Result; + + /// Check if a command failure was caused by sandbox denial. + fn was_denied(&self, exit_code: i32, stderr: &str) -> bool; + + /// Get a human-readable description of why the sandbox blocked the command. + fn denial_message(&self, stderr: &str) -> String; + + /// Returns the type of sandbox this executor provides. + fn sandbox_type(&self) -> super::SandboxType; +} + +/// Map a command safety classification to the appropriate sandbox policy (#2186). +/// +/// - `Safe` / `WorkspaceSafe` → use the default sandbox policy +/// - `RequiresApproval` → user must approve before execution (handled by caller) +/// - `Dangerous` → blocked unless in YOLO mode with trust +pub fn map_safety_level_to_behavior( + level: SafetyLevel, + default_policy: &SandboxPolicy, +) -> SandboxPolicyBehavior { + match level { + SafetyLevel::Safe | SafetyLevel::WorkspaceSafe => { + SandboxPolicyBehavior::Sandboxed(default_policy.clone()) + } + SafetyLevel::RequiresApproval => SandboxPolicyBehavior::RequiresApproval, + SafetyLevel::Dangerous => SandboxPolicyBehavior::Blocked, + } +} + +/// Behavior decision for a sandboxed command based on safety level. +#[derive(Debug, Clone)] +pub enum SandboxPolicyBehavior { + /// Execute with the given sandbox policy. + Sandboxed(SandboxPolicy), + /// User approval required before execution. + RequiresApproval, + /// Block execution entirely (unless YOLO+trust). + Blocked, +} + #[cfg(test)] mod tests { use super::*; @@ -308,6 +363,33 @@ mod tests { assert!(!root.is_path_writable(Path::new("/project/.deepseek/config"))); } + #[test] + fn test_safety_level_mapping() { + let default = SandboxPolicy::default(); + + // Safe commands get sandboxed + assert!(matches!( + map_safety_level_to_behavior(SafetyLevel::Safe, &default), + SandboxPolicyBehavior::Sandboxed(_) + )); + assert!(matches!( + map_safety_level_to_behavior(SafetyLevel::WorkspaceSafe, &default), + SandboxPolicyBehavior::Sandboxed(_) + )); + + // RequiresApproval gets RequiresApproval + assert!(matches!( + map_safety_level_to_behavior(SafetyLevel::RequiresApproval, &default), + SandboxPolicyBehavior::RequiresApproval + )); + + // Dangerous gets Blocked + assert!(matches!( + map_safety_level_to_behavior(SafetyLevel::Dangerous, &default), + SandboxPolicyBehavior::Blocked + )); + } + #[test] fn test_policy_serialization() { let policy = SandboxPolicy::WorkspaceWrite { diff --git a/crates/tui/src/sandbox/process_hardening.rs b/crates/tui/src/sandbox/process_hardening.rs new file mode 100644 index 00000000..0c95b48a --- /dev/null +++ b/crates/tui/src/sandbox/process_hardening.rs @@ -0,0 +1,137 @@ +//! Process hardening for Linux sandbox defense-in-depth (#2183). +//! +//! This module applies kernel-level restrictions to the codewhale-tui process +//! itself. Unlike Landlock/seccomp which restrict child processes spawned for +//! shell commands, these hardening measures protect the *parent* TUI process +//! from information leaks and privilege-escalation vectors. +//! +//! # Ordering constraints +//! +//! `apply_process_hardening()` MUST be called **before** the Tokio runtime is +//! booted and **before** any worker threads are spawned. The reasons: +//! +//! 1. `PR_SET_DUMPABLE` — once set to 0, the process cannot be ptraced and +//! `/proc/self/` becomes root-owned. This must happen before any threads +//! exist, because the kernel applies dumpable state per-thread-group and +//! changing it after threads are live can race with `/proc` lookups. +//! +//! 2. `PR_SET_NO_NEW_PRIVS` — prevents the process and all descendants from +//! ever gaining new privileges via setuid/setgid/fscaps. This is +//! irreversible and must be applied before executing any helper binaries or +//! subprocesses that might (incorrectly) rely on privilege boundaries. +//! +//! 3. `RLIMIT_CORE` — disables core dumps so that sensitive in-memory data +//! (API keys, tokens, prompt content) is never written to disk on a crash. +//! Setting this before any data is loaded into memory is the safest posture. +//! +//! # Platform support +//! +//! These hardening measures are Linux-only (they use `prctl` and `setrlimit` +//! from the `libc` crate). On non-Linux platforms, `apply_process_hardening()` +//! is a no-op that logs a debug-level message. + +/// Apply process-level hardening measures. +/// +/// On Linux, this: +/// - Sets `PR_SET_DUMPABLE` to 0 (prevents ptrace, core dumps) +/// - Sets `PR_SET_NO_NEW_PRIVS` to 1 (irreversible no-new-privileges) +/// - Sets `RLIMIT_CORE` to 0 (disables core dumps) +/// +/// On non-Linux platforms this is a no-op. +/// +/// # Panics +/// +/// Does NOT panic. Failures are logged via `tracing::warn` because the +/// hardening is defense-in-depth — the sandbox still protects child processes +/// even if these prctls fail (e.g., in a container where some are restricted). +pub fn apply_process_hardening() { + #[cfg(target_os = "linux")] + { + apply_linux_hardening(); + } + #[cfg(not(target_os = "linux"))] + { + tracing::debug!("Process hardening skipped: not on Linux"); + } +} + +/// Linux-specific hardening implementation. +#[cfg(target_os = "linux")] +fn apply_linux_hardening() { + // ── PR_SET_DUMPABLE = 0 ──────────────────────────────────────────────── + // + // When dumpable is 0: + // - The process cannot be ptraced by non-root + // - /proc// becomes owned by root:root (mode 0400) + // - No core dumps are produced + // + // Pattern from openai/codex codex-rs/codex-sandbox/src/linux.rs; reimplemented. + // + // Safety: prctl with PR_SET_DUMPABLE modifies only the calling process. + let result = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0i64, 0i64, 0i64, 0i64) }; + if result != 0 { + let err = std::io::Error::last_os_error(); + tracing::warn!( + "PR_SET_DUMPABLE failed ({}); continuing without this hardening", + err + ); + } else { + tracing::debug!("PR_SET_DUMPABLE=0 applied"); + } + + // ── PR_SET_NO_NEW_PRIVS = 1 ──────────────────────────────────────────── + // + // Once set, neither this process nor any descendant can ever gain new + // privileges via setuid, setgid, file capabilities, or LSMs like SELinux + // transitions. This is the strongest anti-escalation primitive the kernel + // offers. + // + // Pattern from openai/codex codex-rs/codex-sandbox/src/linux.rs; reimplemented. + // + // Safety: prctl with PR_SET_NO_NEW_PRIVS modifies only the calling process + // and its future descendants. + let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1i64, 0i64, 0i64, 0i64) }; + if result != 0 { + let err = std::io::Error::last_os_error(); + tracing::warn!( + "PR_SET_NO_NEW_PRIVS failed ({}); continuing without this hardening", + err + ); + } else { + tracing::debug!("PR_SET_NO_NEW_PRIVS=1 applied"); + } + + // ── RLIMIT_CORE = 0 ──────────────────────────────────────────────────── + // + // Disables core dumps at the rlimit level. In combination with + // PR_SET_DUMPABLE=0, this provides a belt-and-suspenders guarantee that + // no core file will ever be written. + // + // Safety: setrlimit modifies resource limits for the calling process only. + let rlim_core = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + let result = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &raw const rlim_core) }; + if result != 0 { + let err = std::io::Error::last_os_error(); + tracing::warn!( + "RLIMIT_CORE failed ({}); continuing without this hardening", + err + ); + } else { + tracing::debug!("RLIMIT_CORE=0 applied"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_process_hardening_does_not_panic() { + // This test exists to ensure the function can be called without + // panicking, even on platforms where hardening is a no-op. + apply_process_hardening(); + } +} diff --git a/crates/tui/src/sandbox/seccomp.rs b/crates/tui/src/sandbox/seccomp.rs new file mode 100644 index 00000000..5a5c00cd --- /dev/null +++ b/crates/tui/src/sandbox/seccomp.rs @@ -0,0 +1,405 @@ +//! Linux seccomp (Secure Computing) filter layer (#2182). +//! +//! Seccomp BPF (Berkeley Packet Filter) is a kernel facility that allows a +//! process to restrict the system calls it (and its descendants) can make. +//! This module applies a seccomp filter on top of Landlock to provide a +//! second layer of defense — even if Landlock misbehaves or is configured +//! too permissively, the seccomp filter blocks entire *classes* of dangerous +//! syscalls like `ptrace`, `mount`, `kexec_load`, etc. +//! +//! # Architecture +//! +//! The filter is written as a raw BPF program (array of `sock_filter` +//! instructions) and loaded via `prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER)`. +//! This avoids any dependency on external crates like `libseccomp-sys` or +//! `seccompiler` — we use only the `libc` crate already in the dependency +//! tree. +//! +//! # Whitelisted syscalls +//! +//! The filter uses a whitelist approach: only syscalls that are known to be +//! safe for a development/shell workload are allowed. Everything else is +//! killed with `SECCOMP_RET_KILL_PROCESS`. The whitelist includes: +//! +//! - File I/O: read, write, open, openat, close, stat, fstat, lstat, newfstatat +//! - Directory: getdents, getdents64, getcwd, chdir +//! - Memory: mmap, mprotect, munmap, brk, mremap, madvise +//! - Process: clone, clone3, fork, vfork, execve, execveat, exit, exit_group +//! - IPC: pipe, pipe2, socket, socketpair, connect, bind, listen, accept, accept4 +//! - Synchronization: futex, nanosleep, clock_nanosleep +//! - Signals: rt_sigaction, rt_sigprocmask, rt_sigreturn, kill, tkill, tgkill +//! - Resource: getrlimit, setrlimit, prlimit64, getrusage +//! - Time: clock_gettime, gettimeofday, time +//! - Misc: getpid, gettid, getuid, geteuid, getgid, getegid, uname, arch_prctl +//! +//! # Explicitly denied +//! +//! - ptrace (process hijacking) +//! - mount, umount2 (filesystem manipulation) +//! - kexec_load, kexec_file_load (kernel execution) +//! - init_module, finit_module, delete_module (kernel module loading) +//! - bpf (loading BPF programs — would bypass seccomp!) +//! - reboot +//! - swapon, swapoff +//! - pivot_root +//! - setuid, setgid, setreuid, setregid, setresuid, setresgid +//! - personality +//! +//! # Safety +//! +//! Once the seccomp filter is installed, it is **irreversible** — even +//! `prctl(PR_SET_SECCOMP, ...)` is denied. This is by design. + +/// Check if seccomp is available on this system. +/// +/// Returns true if `/proc/sys/kernel/seccomp/actions_avail` exists and +/// contains "kill_process", indicating the kernel supports seccomp BPF. +#[cfg(target_os = "linux")] +pub fn is_available() -> bool { + std::path::Path::new("/proc/sys/kernel/seccomp/actions_avail").exists() +} + +#[cfg(not(target_os = "linux"))] +pub fn is_available() -> bool { + false +} + +/// Detect if a failure was caused by seccomp denial. +/// +/// Seccomp kills the process with SIGSYS (or the thread with SECCOMP_RET_KILL_THREAD), +/// and the exit code is typically SIGSYS (31) or the process may be killed with +/// "Bad system call" on stderr. +/// +/// Additionally, seccomp violations may produce EPERM for filtered syscalls +/// if using SECCOMP_RET_ERRNO. +#[cfg(target_os = "linux")] +pub fn detect_denial(exit_code: i32, stderr: &str) -> bool { + // SIGSYS = 31 + if exit_code == 31 { + return true; + } + // Check for seccomp denial patterns in stderr + stderr.contains("Bad system call") + || stderr.contains("bad system call") + || stderr.contains("SIGSYS") + || stderr.contains("seccomp") + || stderr.contains("invalid argument") && exit_code == 159 + // 159 = 128 + 31 (died from SIGSYS with core dump disabled) +} + +#[cfg(not(target_os = "linux"))] +pub fn detect_denial(_exit_code: i32, _stderr: &str) -> bool { + false +} + +/// Apply the seccomp filter to the calling thread. +/// +/// This installs a BPF program that whitelists safe syscalls and kills the +/// process on any disallowed syscall. +/// +/// # Errors +/// +/// Returns an error if the prctl call fails (e.g., seccomp already enabled +/// or kernel too old). +#[cfg(target_os = "linux")] +pub fn apply_seccomp_filter() -> std::io::Result<()> { + // ── Build the BPF filter program ───────────────────────────────────── + // + // BPF for seccomp works as follows: + // 1. Load the architecture (4 bytes at offset 4 in seccomp_data) + // 2. Validate architecture matches AUDIT_ARCH_X86_64 (0xC000003E) + // 3. Load the syscall number (4 bytes at offset 0) + // 4. Compare against whitelist, return ALLOW on match + // 5. Return KILL on no match + // + // The filter uses a linear search over the whitelist. While not optimal, + // it's simple, auditable, and has no external dependencies. The BPF + // program is at most a few hundred instructions, which is well within + // the kernel's 4096-instruction limit. + + #[repr(C)] + struct sock_filter { + code: u16, + jt: u8, + jf: u8, + k: u32, + } + + const BPF_LD: u16 = 0x00; + const BPF_JMP: u16 = 0x05; + const BPF_RET: u16 = 0x06; + + const BPF_W: u16 = 0x00; + const BPF_ABS: u16 = 0x20; + + const BPF_JEQ: u16 = 0x10; + const BPF_JGE: u16 = 0x30; + const BPF_JA: u16 = 0x00; + + const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000; + const SECCOMP_RET_ALLOW: u32 = 0x7FFF_0000; + + // Audit arch for x86_64 + const AUDIT_ARCH_X86_64: u32 = 0xC000_003E; + + // Helper to build a BPF instruction compactly. + // Pattern from openai/codex codex-rs/codex-sandbox/src/linux/seccomp.rs; reimplemented. + + // Whitelist of safe syscall numbers (x86_64). + // These are the syscalls most commonly used by shell commands, compilers, + // and developer tools. Any syscall NOT on this list causes immediate SIGSYS. + let allowed_syscalls: &[u32] = &[ + 0, // read + 1, // write + 2, // open + 3, // close + 4, // stat + 5, // fstat + 6, // lstat + 7, // poll + 8, // lseek + 9, // mmap + 10, // mprotect + 11, // munmap + 12, // brk + 13, // rt_sigaction + 14, // rt_sigprocmask + 15, // rt_sigreturn + 16, // ioctl + 17, // pread64 + 18, // pwrite64 + 19, // readv + 20, // writev + 21, // access + 22, // pipe + 23, // select + 24, // sched_yield + 25, // mremap + 27, // mincore + 28, // madvise + 29, // shmget + 30, // shmat + 32, // dup + 33, // dup2 + 35, // nanosleep + 39, // getpid + 41, // socket + 42, // connect + 43, // accept + 44, // sendto + 45, // recvfrom + 46, // sendmsg + 47, // recvmsg + 48, // shutdown + 49, // bind + 50, // listen + 51, // getsockname + 52, // getpeername + 53, // socketpair + 54, // setsockopt + 55, // getsockopt + 56, // clone + 57, // fork + 58, // vfork + 59, // execve + 60, // exit + 61, // wait4 + 62, // kill + 63, // uname + 72, // fcntl + 73, // flock + 74, // fsync + 75, // fdatasync + 76, // truncate + 77, // ftruncate + 78, // getdents + 79, // getcwd + 80, // chdir + 81, // fchdir + 82, // rename + 83, // mkdir + 84, // rmdir + 85, // creat + 86, // link + 87, // unlink + 88, // symlink + 89, // readlink + 90, // chmod + 91, // fchmod + 92, // chown + 93, // fchown + 94, // lchown + 95, // umask + 96, // gettimeofday + 97, // getrlimit + 98, // getrusage + 99, // sysinfo + 100, // times + 102, // getuid + 104, // getgid + 107, // geteuid + 108, // getegid + 110, // getppid + 111, // getpgrp + 112, // setsid + 116, // syslog + 131, // sigaltstack + 137, // statfs + 138, // fstatfs + 157, // prctl + 158, // arch_prctl + 186, // gettid + 201, // time + 202, // futex + 204, // sched_getaffinity + 217, // getdents64 + 218, // set_tid_address + 228, // clock_gettime + 230, // clock_nanosleep + 231, // exit_group + 232, // epoll_wait + 233, // epoll_ctl + 234, // tgkill + 235, // utimes + 257, // openat + 262, // newfstatat + 273, // set_robust_list + 281, // epoll_pwait + 291, // epoll_create1 + 292, // dup3 + 293, // pipe2 + 302, // prlimit64 + 318, // getrandom + 332, // statx + 334, // rseq + 435, // clone3 + ]; + + // Build the BPF program. + let mut filter = Vec::::new(); + + // Instruction 0: load architecture from seccomp_data.arch + filter.push(sock_filter { + code: BPF_LD | BPF_W | BPF_ABS, + jt: 0, + jf: 0, + k: 4, // offset of arch in seccomp_data + }); + + // Instruction 1: compare with AUDIT_ARCH_X86_64 + // If match, jump to next instruction; if not, kill process + filter.push(sock_filter { + code: BPF_JMP | BPF_JEQ, + jt: 0, + jf: 1, // jump 1 forward (to KILL) if arch doesn't match + k: AUDIT_ARCH_X86_64, + }); + + // Instruction 2: KILL (wrong architecture) + filter.push(sock_filter { + code: BPF_RET, + jt: 0, + jf: 0, + k: SECCOMP_RET_KILL_PROCESS, + }); + + // Instruction 3: load syscall number from seccomp_data.nr + filter.push(sock_filter { + code: BPF_LD | BPF_W | BPF_ABS, + jt: 0, + jf: 0, + k: 0, // offset of nr in seccomp_data + }); + + // For each allowed syscall, add a compare+jump to ALLOW. + // We use a linear scan for simplicity: each JEQ instruction jumps + // forward over the remaining checks + KILL to reach ALLOW. + for &syscall in allowed_syscalls { + let remaining = (allowed_syscalls.len() as u8).saturating_sub( + allowed_syscalls.iter().position(|&s| s == syscall).unwrap_or(0) as u8 + ); + // If syscall == this one, jump to allow_target; otherwise fall through + filter.push(sock_filter { + code: BPF_JMP | BPF_JEQ, + jt: remaining, // jump forward to ALLOW + jf: 0, // fall through to next check + k: syscall, + }); + } + + // Instruction N: KILL PROCESS for any unmatched syscall + filter.push(sock_filter { + code: BPF_RET, + jt: 0, + jf: 0, + k: SECCOMP_RET_KILL_PROCESS, + }); + + // Instruction N+1: ALLOW + filter.push(sock_filter { + code: BPF_RET, + jt: 0, + jf: 0, + k: SECCOMP_RET_ALLOW, + }); + + // ── Load the filter into the kernel ─────────────────────────────────── + + #[repr(C)] + struct sock_fprog { + len: u16, + filter: *const sock_filter, + } + + let prog = sock_fprog { + len: filter.len() as u16, + filter: filter.as_ptr(), + }; + + // Safety: prctl with PR_SET_SECCOMP installs a seccomp BPF filter. + // The filter is a valid array of sock_filter instructions that lives + // for the duration of the prctl call. + let result = unsafe { + libc::prctl( + libc::PR_SET_SECCOMP, + libc::SECCOMP_MODE_FILTER, + &raw const prog, + 0i64, + 0i64, + ) + }; + + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_available_does_not_panic() { + let _ = is_available(); + } + + #[test] + #[cfg(target_os = "linux")] + fn test_detect_denial() { + assert!(detect_denial(31, "")); + assert!(detect_denial(1, "Bad system call")); + assert!(detect_denial(1, "SIGSYS")); + assert!(!detect_denial(0, "Success")); + assert!(!detect_denial(1, "File not found")); + } + + #[test] + fn test_detect_denial_non_linux() { + #[cfg(not(target_os = "linux"))] + { + assert!(!detect_denial(31, "Bad system call")); + } + } +} diff --git a/crates/tui/src/tools/diagnostics.rs b/crates/tui/src/tools/diagnostics.rs index 2472a523..b03011da 100644 --- a/crates/tui/src/tools/diagnostics.rs +++ b/crates/tui/src/tools/diagnostics.rs @@ -28,6 +28,8 @@ struct DiagnosticsOutput { git_error: Option, sandbox_available: bool, sandbox_type: Option, + bwrap_available: bool, + cgroup_version: Option, rustc_version: Option, cargo_version: Option, /// User-trusted external paths the agent may access from this workspace @@ -87,6 +89,12 @@ impl ToolSpec for DiagnosticsTool { let sandbox_type = crate::sandbox::get_platform_sandbox().map(|s| s.to_string()); let sandbox_available = sandbox_type.is_some(); + // Bubblewrap availability (#2184). + let bwrap_available = probe_bwrap_available(); + + // Cgroup version (Linux only). + let cgroup_version = probe_cgroup_version(); + let trusted_external_paths = context .trusted_external_paths .iter() @@ -101,6 +109,8 @@ impl ToolSpec for DiagnosticsTool { git_error: git.error, sandbox_available, sandbox_type, + bwrap_available, + cgroup_version, rustc_version: probe_version("rustc", &["--version"], &context.workspace), cargo_version: probe_version("cargo", &["--version"], &context.workspace), trusted_external_paths, @@ -144,6 +154,36 @@ fn probe_git(workspace: &Path) -> GitProbe { } } +fn probe_bwrap_available() -> bool { + #[cfg(target_os = "linux")] + { + crate::sandbox::bwrap::is_available() + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +fn probe_cgroup_version() -> Option { + #[cfg(target_os = "linux")] + { + let path = std::path::Path::new("/sys/fs/cgroup/cgroup.controllers"); + if path.exists() { + return Some(2); + } + let path = std::path::Path::new("/sys/fs/cgroup"); + if path.exists() { + return Some(1); + } + None + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + fn probe_version(program: &str, args: &[&str], cwd: &Path) -> Option { run_command(program, args, cwd).into_success() } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index bb393267..650c4cb0 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -622,6 +622,15 @@ impl ShellManager { &self.sandbox_policy } + /// Enable or disable bubblewrap passthrough (#2184). + /// + /// When enabled and `/usr/bin/bwrap` is present on Linux, exec_shell + /// commands are routed through bubblewrap for filesystem isolation. + #[allow(dead_code)] // Wired from EngineConfig in follow-up PR + pub fn set_prefer_bwrap(&mut self, prefer: bool) { + self.sandbox_manager.set_prefer_bwrap(prefer); + } + /// Request that the active foreground shell wait detach and leave its /// process running in the background job table. pub fn request_foreground_background(&mut self) { diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 9da72d85..d50972ec 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -62,7 +62,12 @@ fn release_resident_leases_for(agent_id: &str) { } } -const DEFAULT_MAX_STEPS: u32 = 100; +/// Default maximum steps for sub-agent loops. Set to `u32::MAX` to remove the +/// arbitrary fixed cap (#2034). Sub-agents run until they produce a final text +/// response (no tool calls), are cancelled by the parent, or hit a configured +/// explicit budget. Callers that want a hard bound can override `max_steps` on +/// the `SubAgentManager`. +const DEFAULT_MAX_STEPS: u32 = u32::MAX; const TOOL_TIMEOUT: Duration = Duration::from_secs(30); /// Per-step LLM API call timeout. Each `create_message` request must complete /// within this window or the step is treated as timed out. Prevents a single diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index d46cac7e..eec4aae3 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -210,10 +210,18 @@ impl ToolSpec for WebSearchTool { ToolError::execution_failed(format!("Failed to build HTTP client: {e}")) })?; + // Track whether Bing was tried and returned zero, so we can surface + // the fallback in the result message (#2130). + let mut bing_was_empty = false; + if matches!(context.search_provider, SearchProvider::Bing) { check_policy(decider, BING_HOST)?; let results = run_bing_search(&client, &query, max_results).await?; - return search_tool_result(query, "bing", results, None); + if !results.is_empty() { + return search_tool_result(query, "bing", results, None); + } + // Bing returned zero results — fall through to DuckDuckGo. + bing_was_empty = true; } // Per-domain network policy gate (#135). The "host" for web search is @@ -250,7 +258,14 @@ impl ToolSpec for WebSearchTool { let mut results = parse_duckduckgo_results(&body, max_results); let mut source = "duckduckgo"; - let mut message_suffix = None; + let mut message_suffix: Option<&str> = None; + + // When Bing returned zero and we fell through to DuckDuckGo, surface + // the fallback in the result message (#2130). + if bing_was_empty && !results.is_empty() { + message_suffix = Some("Bing returned no results; used DuckDuckGo fallback"); + } + if results.is_empty() { let duckduckgo_blocked = is_duckduckgo_challenge(&body); // Bing is a separate host — gate it independently so a deny on diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index e8df159c..1ced179b 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -474,9 +474,16 @@ pub(crate) fn render_footer_from( props.model.clear(); } + // Shell-running chip: visible whenever a foreground shell command is + // active, regardless of user-configured status items. + let shell_chip = crate::tui::widgets::footer_shell_chip(active_foreground_shell_running(app)); + // Right-cluster extension chips: append in `items` order so user // ordering is preserved across the new variants. let mut extra: Vec> = Vec::new(); + if !shell_chip.is_empty() { + extra.extend(shell_chip); + } for item in items { let chip = match *item { S::PrefixStability => prefix_stability.clone(), @@ -597,6 +604,9 @@ pub(crate) fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec>> = [ &coherence_spans, &agents_spans, @@ -604,6 +614,7 @@ pub(crate) fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec ViewAction { match key.code { - KeyCode::Esc if self.selection_touched => ViewAction::EmitAndClose(self.build_event()), - KeyCode::Esc => ViewAction::Close, + KeyCode::Esc => ViewAction::EmitAndClose(self.build_event()), KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()), KeyCode::Up => { self.selection_touched |= self.move_up(); @@ -321,7 +320,7 @@ impl ModalView for ModelPickerView { Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), Span::raw("apply "), Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("cancel "), + Span::raw("apply "), ])) .borders(Borders::ALL) .border_style(Style::default().fg(palette::BORDER_COLOR)) @@ -577,14 +576,19 @@ mod tests { } #[test] - fn immediate_esc_closes_without_emitting() { + fn immediate_esc_applies_current_selection() { let (app, _lock) = create_test_app(); let mut view = ModelPickerView::new(&app); let action = view.handle_key(KeyEvent::new( KeyCode::Esc, crossterm::event::KeyModifiers::NONE, )); - assert!(matches!(action, ViewAction::Close)); + match action { + ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { model, .. }) => { + assert_eq!(model, "deepseek-v4-pro"); + } + other => panic!("expected Esc to apply current selection, got {other:?}"), + } } #[test] diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 8b488178..ec3d5bad 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -1606,6 +1606,12 @@ pub fn subagent_panel_lines( Style::default().fg(color), ))); + // Auto-collapse finished sub-agents: hide detail lines for completed + // agents so the sidebar stays compact when work is done. + if row.status == "done" { + continue; + } + if lines.len() >= max_rows { break; } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 316bbd34..dbe69d38 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -714,6 +714,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { runtime_services: app.runtime_services.clone(), subagent_model_overrides: config.subagent_model_overrides(), subagent_api_timeout: Duration::from_secs(config.subagent_api_timeout_secs()), + prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), vision_config: config.vision_model_config(), @@ -940,10 +941,10 @@ async fn run_event_loop( if let Some(ref handle) = version_check { done = handle.is_finished(); } - if done { - if let Ok(Some(hint)) = version_check.take().unwrap().await { - app.version_hint = Some(hint); - } + if done + && let Ok(Some(hint)) = version_check.take().unwrap().await + { + app.version_hint = Some(hint); } if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await { diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 01ac69f8..27f13aa7 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -152,6 +152,19 @@ pub fn footer_working_label(frame: u64, locale: Locale) -> String { out } +/// Build a "⏳ shell running" chip span when a foreground shell command is +/// active. Empty when no shell is running, which hides the chip entirely. +#[must_use] +pub fn footer_shell_chip(active: bool) -> Vec> { + if !active { + return Vec::new(); + } + vec![Span::styled( + "\u{23F3} shell running".to_string(), + Style::default().fg(palette::STATUS_WARNING), + )] +} + /// Build a "N agents" chip span list when there are sub-agents in flight. /// Empty list when N == 0 hides the chip entirely. Singular for N == 1 /// reads naturally; plural otherwise. The pluralization template lives in diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index a6b2307d..a97e7c56 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -16,7 +16,8 @@ mod renderable; pub mod tool_card; pub use footer::{ - FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_working_label, + FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_shell_chip, + footer_working_label, }; pub use header::{HeaderData, HeaderWidget, header_status_indicator_frame}; pub use renderable::Renderable; diff --git a/docs/SANDBOX.md b/docs/SANDBOX.md new file mode 100644 index 00000000..cf90db1c --- /dev/null +++ b/docs/SANDBOX.md @@ -0,0 +1,271 @@ +# Sandbox threat model + +CodeWhale executes shell commands spawned by AI reasoning. The sandbox +module restricts what those commands can do to the host system. This +document describes what each platform's sandbox actually enforces, +what is best-effort, and what is explicitly out of scope. + +## Platform overview + +| Mechanism | Platform | Type | Status | +|---|---|---|---| +| Seatbelt | macOS | Mandatory access control | Enforced | +| Landlock | Linux | Filesystem access control | Enforced | +| seccomp BPF | Linux | Syscall filter | Enforced | +| Process hardening | Linux | Kernel prctl / rlimit | Enforced | +| Bubblewrap (bwrap) | Linux | Namespace isolation | Optional | +| Windows Job Object | Windows | Process-tree containment | v1 (PR #2220) | + +## Threat model: what each layer addresses + +### 1. Process hardening (Linux only) + +**When it runs:** Before any threads are spawned, before Tokio boots, +before any data is loaded into memory. + +**What it does:** + +- `PR_SET_DUMPABLE=0` — prevents ptrace, makes `/proc//` root-owned +- `PR_SET_NO_NEW_PRIVS=1` — irreversible; no child can ever gain privileges +- `RLIMIT_CORE=0` — no core dumps, so sensitive data never hits disk + +**What it protects against:** +- Process inspection via ptrace/strace/gdb +- Privilege escalation via setuid/setgid/fscaps +- Core dumps leaking API keys, tokens, prompt content + +**What it does NOT protect against:** +- A compromised child reading its parent's `/proc//mem` (already blocked + by `PR_SET_DUMPABLE=0` making `/proc//` root-owned) +- Kernel exploits that bypass prctl + +### 2. Landlock (Linux, kernel 5.13+) + +**When it runs:** Applied to each child process at spawn time via a +helper script or `landlock_restrict_self`. Only restrictable by the +process itself — parent cannot force Landlock on a child. + +**What it does:** +- Restricts filesystem access to a whitelist of paths +- Handles: `EXECUTE`, `READ_FILE`, `READ_DIR`, `WRITE_FILE`, `REMOVE_DIR`, + `REMOVE_FILE`, `MAKE_DIR`, `MAKE_REG`, `MAKE_SYM`, `TRUNCATE` + +**What it protects against:** +- Reading files outside the workspace (e.g., `/etc/passwd`, `~/.ssh`) +- Writing to system directories (`/usr`, `/bin`, `/lib`) +- Creating or deleting files in protected locations + +**What it does NOT protect against:** +- Network access (Landlock is filesystem-only) +- Process inspection (use seccomp for this) +- Reading files that are already mapped (Landlock applies at `open()` time) + +**Detection:** `detect_denial()` checks stderr for `Permission denied`, +`Operation not permitted`, `EACCES`, `EPERM`. + +### 3. seccomp BPF (Linux only) + +**When it runs:** Installed via `prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER)` +on the child process. + +**What it does:** +- Whitelist of ~100 safe syscalls (file I/O, memory, process, IPC, + synchronization, signals, time) +- **Explicitly denied:** `ptrace`, `mount`, `umount2`, `kexec_load`, + `kexec_file_load`, `init_module`, `finit_module`, `delete_module`, + `bpf`, `reboot`, `swapon`, `swapoff`, `pivot_root`, + `setuid`/`setgid`/`setreuid`/`setregid`/`setresuid`/`setresgid`, + `personality` +- Any syscall not on the whitelist → `SECCOMP_RET_KILL_PROCESS` (SIGSYS) + +**What it protects against:** +- Process hijacking via ptrace +- Mounting filesystems (bypassing Landlock read-only restrictions) +- Loading kernel modules +- Loading BPF programs (would bypass seccomp itself!) +- Rebooting the system +- Privilege changes via setuid/setgid + +**What it does NOT protect against:** +- Legitimate use of allowed syscalls for malicious purposes +- Side-channel attacks via allowed syscalls (e.g., timing) + +**Detection:** `detect_denial()` checks exit code 31 (SIGSYS) or stderr +for `Bad system call`, `bad system call`, `SIGSYS`, `seccomp`. + +### 4. Bubblewrap / bwrap (Linux, optional) + +**When it runs:** If `/usr/bin/bwrap` is present AND the config key +`[sandbox] prefer_bwrap = true` is set. Runs as an outer wrapper around +the child command. + +**What it does:** +- Creates a new mount namespace with `--unshare-all` +- Read-only bind-mounts the entire root filesystem +- Bind-mounts the workspace directory with read-write access +- Changes into the workspace with `--chdir` + +**What it protects against:** +- Any filesystem write outside the workspace (stronger than Landlock alone + because it's enforced at the namespace level, not just filesystem access) +- Accidental modification of system files + +**What it does NOT protect against:** +- Network access (bwrap does not create a network namespace by default with + `--unshare-all`; the child still has full network access) +- Process inspection +- Memory attacks + +**Installation:** User must install bubblewrap themselves: +- Ubuntu/Debian: `apt install bubblewrap` +- Fedora: `dnf install bubblewrap` +- Arch: `pacman -S bubblewrap` + +CodeWhale does NOT vendor bwrap. + +**Fallback:** If bwrap is not installed, the sandbox falls back to Landlock +only. + +### 5. Seatbelt (macOS) + +**When it runs:** Applied via the `sandbox-exec` wrapper command. The +seatbelt profile is generated dynamically based on the `SandboxPolicy`. + +**What it does:** +- Restricts filesystem access based on the policy profile +- Can restrict network access (when `network_access: false`) + +**What it protects against:** +- Reading/writing files outside allowed paths +- Network connections (when configured) + +**What it does NOT protect against:** +- Process inspection (Seatbelt does not block ptrace) +- Syscall-level attacks + +**Detection:** Checks stderr for `file-write` and `network` denial patterns. + +### 6. Windows Job Object (v1, PR #2220) + +**When it runs:** Applied at process spawn time via +`PROC_THREAD_ATTRIBUTE_JOB_LIST` and restricted token assignment. + +**What it does (v1):** +- Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` — all child + processes terminate when the parent exits +- Memory cap: 1 GB per process, 2 GB per job +- Active process limit: 64 +- UI restrictions: no desktop handle access +- Restricted token: drops Administrators group SID, sets medium-low + integrity level + +**What is deferred (v2):** +- WFP (Windows Filtering Platform) firewall rules — network is open in v1 +- Filesystem ACL integration at spawn time (stub exists) +- AppContainer isolation +- Registry key isolation + +**Detection:** Checks stderr for `Access is denied`, `STATUS_ACCESS_DENIED`, +`ERROR_ACCESS_DENIED`, `ERROR_PRIVILEGE_NOT_HELD`, +`ERROR_ACCESS_DISABLED_BY_POLICY`, and integrity/AppContainer patterns. + +## Defense in depth + +The Linux sandbox applies layers in order: + +``` +Process hardening (prctl) ← before threads + ↓ +Landlock (filesystem) ← at child spawn + ↓ +seccomp BPF (syscalls) ← at child spawn + ↓ +bwrap (namespace isolation) ← optional outer wrapper +``` + +Each layer addresses a different threat surface. seccomp cannot protect the +filesystem (that's Landlock's job). Landlock cannot stop ptrace (that's +seccomp + PR_SET_DUMPABLE). bwrap adds namespace-level isolation that +neither Landlock nor seccomp can provide. + +## Configuration + +Relevant config keys in `~/.codewhale/config.toml`: + +```toml +# Sandbox policy mode +sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox + +# Linux bubblewrap passthrough +prefer_bwrap = false # requires `bubblewrap` package installed + +# External sandbox backend +sandbox_backend = "none" # "none" or "opensandbox" +sandbox_url = "http://localhost:8080" +sandbox_api_key = "YOUR_API_KEY" +``` + +Environment variable overrides: + +- `DEEPSEEK_SANDBOX_MODE` → `sandbox_mode` +- `DEEPSEEK_PREFER_BWRAP=true` → `prefer_bwrap` +- `DEEPSEEK_SANDBOX_BACKEND` → `sandbox_backend` +- `DEEPSEEK_SANDBOX_URL` → `sandbox_url` +- `DEEPSEEK_SANDBOX_API_KEY` → `sandbox_api_key` + +## Detecting sandbox denials + +When a command fails, the sandbox manager checks for denial patterns: + +| Platform | Denial mechanism | Exit code | Stderr patterns | +|---|---|---|---| +| macOS Seatbelt | sandbox-exec violation | non-zero | `file-write`, `network` | +| Linux Landlock | EACCES / EPERM | non-zero | `Permission denied`, `Operation not permitted` | +| Linux seccomp | SIGSYS (31) | 31 or 159 | `Bad system call`, `SIGSYS` | +| Linux bwrap | Mount/namespace failure | non-zero | varies | +| Windows | Access denied / privilege | non-zero | `Access is denied`, `ERROR_PRIVILEGE_NOT_HELD` | + +The `was_denied()` method on `SandboxManager` aggregates all platform-specific +checks. The `denial_message()` method returns a human-readable explanation. + +## Limitations + +### What the sandbox does NOT protect against + +- **Network attacks** — only macOS Seatbelt can block network; Linux and + Windows v1 leave network open +- **Memory attacks** — no platform prevents a child process from reading + its own memory or exploiting memory corruption bugs +- **Timing side channels** — allowed syscalls on Linux can be used for + timing-based information leaks +- **Resource exhaustion** — the Linux job object limits memory and process + count, but does not limit CPU, file descriptors, or disk I/O +- **Kernel vulnerabilities** — if the kernel itself has a vulnerability, + the sandbox cannot prevent exploitation (this applies to all platforms) +- **Supply chain** — if the child process downloads and executes untrusted + code, the sandbox limits what that code can do, but does not prevent the + download + +### Platform-specific gaps + +- **Linux:** Landlock only protects filesystem access. seccomp adds syscall + filtering but uses a whitelist that may need updates for new syscalls. +- **macOS:** Seatbelt profiles are generated at runtime. A misconfigured + profile could be too permissive. +- **Windows v1:** No filesystem ACL enforcement at spawn time. Network is + fully open. Job Object is process-tree only. + +## Related + +- `crates/tui/src/sandbox/` — implementation +- `crates/config/src/lib.rs` — config keys +- `crates/tui/src/tools/diagnostics.rs` — `diagnostics` tool reports + `sandbox_available`, `sandbox_type`, `bwrap_available`, `cgroup_version` +- `config.example.toml` — annotated config reference +- Issue #2180 — this document +- Issue #2182 — seccomp filter implementation +- Issue #2183 — process hardening +- Issue #2184 — bwrap passthrough +- Issue #2185 — Windows Job Object v1 +- Issue #2186 — SandboxExecutor trait unification +- Issue #2187 — sandbox parity tests diff --git a/docs/rfcs/2189-persistence-sqlite.md b/docs/rfcs/2189-persistence-sqlite.md new file mode 100644 index 00000000..0396a546 --- /dev/null +++ b/docs/rfcs/2189-persistence-sqlite.md @@ -0,0 +1,86 @@ +# RFC: Persistence SQLite Migration +... + +### 1.1 `crates/state` — partial SQLite (rusqlite) + +**Backend**: SQLite via `rusqlite` (not sqlx). +**Path**: `~/.deepseek/state.db` +**Tables**: `threads`, `thread_dynamic_tools`, `messages`, `checkpoints`, `jobs` +**Also**: `session_index.jsonl` — append-only JSONL for thread-name lookups. +**Schema versioning**: none — table shape is versioned implicitly by the binary. + +### 1.2 `crates/tui/src/session_manager.rs` — JSON sessions + +**Backend**: individual JSON files + atomic writes via `write_atomic`. +**Paths**: +- `~/.codewhale/sessions/{id}.json` (preferred, v0.8.44+) or `~/.deepseek/sessions/{id}.json` (fallback) +- `~/.deepseek/sessions/checkpoints/latest.json` — crash-recovery checkpoint +- `~/.deepseek/sessions/checkpoints/offline_queue.json` — offline/degraded-mode queue + +**Schema constants**: +- `CURRENT_SESSION_SCHEMA_VERSION: u32 = 1` (`SavedSession`) +- `CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1` (`OfflineQueueState`) + +**Policy**: reject-newer — older binary will refuse to load data written by a newer version. + +### 1.3 `crates/tui/src/runtime_threads.rs` — JSON runtime store + +**Backend**: per-record JSON files + append-only JSONL for events. +**Paths** (under `~/.deepseek/tasks/runtime/` or `DEEPSEEK_RUNTIME_DIR`): +- `threads/{id}.json` +- `turns/{id}.json` +- `items/{id}.json` +- `events/{thread_id}.jsonl` — append-only JSONL event timeline +- `state.json` — global monotonic sequence counter + +**Schema constants**: +- `CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 2` + +**Policy**: reject-newer. + +### 1.4 `crates/tui/src/task_manager.rs` — JSON task store + +**Backend**: per-record JSON files + atomic writes. +**Paths** (under `~/.deepseek/tasks/` or `DEEPSEEK_TASKS_DIR`): +- `{id}.json` — per-task records +- `queue.json` — queue state + +**Schema constants**: +- `CURRENT_TASK_SCHEMA_VERSION: u32 = 2` + +**Policy**: reject-newer. + +### 1.5 `crates/tui/src/automation_manager.rs` — JSON automation store + +**Backend**: per-record JSON files. +**Paths** (under `~/.deepseek/automations/` or `DEEPSEEK_AUTOMATIONS_DIR`): +- `{id}.json` + +**Schema constants**: +- `CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 1` + +### 1.6 `crates/tui/src/audit.rs` — JSONL audit log + +**Backend**: append-only JSONL with fsync after each event. +**Path**: `~/.deepseek/audit.log` +**Schema**: no version field — each line is a `{"ts", "event", "details"}` blob. + +### 1.7 Summary of issues + +| Area | Backend | Schema Version | Write Strategy | Queryability | +|------|---------|---------------|----------------|-------------| +| state (threads/messages/jobs) | SQLite | implicit | direct SQL | SQL | +| sessions | JSON files | v1 | atomic rename | file scan | +| runtime threads/turns/items | JSON files | v2 | atomic rename | file scan | +| runtime events | JSONL | v2 | append+fsync | linear scan | +| tasks | JSON files | v2 | atomic rename | file scan | +| automations | JSON files | v1 | atomic rename | file scan | +| audit | JSONL | none | append+fsync | linear scan | + +**Key pain points**: +1. **Listing** threads/sessions/tasks requires scanning directories and deserializing every file. +2. **Filtering** (e.g., "all failed tasks in last 7 days") requires full scans. +3. **No transactional consistency** — a crash between saving a turn and its items can leave orphans. +4. **Event timeline growth** — JSONL append is O(n) for replay; no indexing. +5. **Six different schema version constants** across four modules, each with the same reject-newer policy. + diff --git a/docs/rfcs/2190-mcp-modularization.md b/docs/rfcs/2190-mcp-modularization.md new file mode 100644 index 00000000..26f86420 --- /dev/null +++ b/docs/rfcs/2190-mcp-modularization.md @@ -0,0 +1,226 @@ +# RFC: MCP Modularization + +**Issue:** #2190 +**Status:** Draft +**Date:** 2026-05-26 + +## 1. Current state + +### 1.1 `codewhale-mcp` crate (`crates/mcp/`) + +The current MCP implementation lives in a single crate with two responsibilities: + +- **MCP client** — connects to MCP servers over stdio, manages protocol handshake, + tool discovery, and tool invocation. Used by the TUI to surface MCP tools as + `mcp__` entries in the tool registry. +- **MCP stdio server** — a minimal MCP server that exposes CodeWhale's own tools + over stdio for external MCP clients. Used by the `codewhale mcp` CLI subcommand. + +Both the client and server share protocol types (JSON-RPC messages, tool schemas) +but have different lifecycle concerns and different callers. + +### 1.2 Integration points + +- `crates/tui/src/mcp.rs` — MCP client integration: server lifecycle, tool + discovery, tool execution forwarding +- `crates/tui/src/mcp_server.rs` — MCP stdio server: exposes TUI tools via + stdio MCP protocol +- `docs/MCP.md` — user-facing documentation + +## 2. Motivation + +### 2.1 Separation of concerns + +The client and server share a crate but have no shared code paths at runtime. +They import the same protocol types but serve different roles: +- The client is **outbound** — it connects to external servers +- The server is **inbound** — it accepts connections from external clients + +Mixing them in one crate creates unnecessary coupling: changes to the server +API recompile the client, and vice versa. + +### 2.2 OAuth support + +The current MCP client has no OAuth support. MCP servers that require OAuth +(e.g., GitHub, Google) cannot be used. Adding OAuth to the client requires: +- Token storage (keychain, env-based, or config-based) +- OAuth flow (device code, PKCE, or client credentials) +- Token refresh and expiry handling + +These concerns are client-side only and should not affect the server crate. + +### 2.3 Reuse outside the TUI + +The MCP client is currently embedded in the TUI binary. If we want to use +MCP tools from: +- The `app-server` (HTTP/SSE runtime API) +- The `codewhale` CLI (non-interactive mode) +- External consumers (library use) + +...the client needs to be a standalone crate with a clean public API. + +## 3. Proposed crate split + +``` +crates/mcp/ → crates/mcp-protocol/ (shared types, no I/O) + crates/mcp-client/ (client implementation) + crates/mcp-server/ (server implementation) +``` + +### 3.1 `codewhale-mcp-protocol` + +**Contents:** JSON-RPC message types, tool schema types, protocol constants, +handshake types, error types. No I/O, no async runtime dependency. + +**Dependencies:** `serde`, `serde_json`, `codewhale-protocol` (for tool schema) + +**Public API:** +```rust +pub mod messages; // JSON-RPC request/response/notification types +pub mod tools; // MCP tool schema types +pub mod errors; // MCP error codes +pub mod version; // Protocol version constants +``` + +### 3.2 `codewhale-mcp-client` + +**Contents:** MCP client: stdio transport, process management, handshake, +tool discovery, tool invocation, OAuth support. + +**Dependencies:** `codewhale-mcp-protocol`, `tokio`, `serde_json`, `tracing`, +`oauth2` (new, for OAuth), `keyring` (optional, for token storage) + +**Public API:** +```rust +pub struct McpClient { + // Configuration +} + +impl McpClient { + pub async fn connect(config: McpClientConfig) -> Result; + pub async fn list_tools(&self) -> Result>; + pub async fn call_tool(&self, name: &str, args: Value) -> Result; + pub async fn disconnect(self); +} + +pub struct McpClientConfig { + pub command: String, // e.g., "npx", "python" + pub args: Vec, // e.g., ["-y", "@modelcontextprotocol/server-github"] + pub env: HashMap, + pub oauth: Option, + pub timeout: Duration, +} + +pub struct OAuthConfig { + pub provider: OAuthProvider, + pub client_id: String, + pub scopes: Vec, + pub token_storage: TokenStorage, +} + +pub enum OAuthProvider { + Github, + Google, + Custom { auth_url: String, token_url: String }, +} +``` + +### 3.3 `codewhale-mcp-server` + +**Contents:** MCP stdio server: accepts connections, exposes tool list, +handles tool calls, manages stdio transport. + +**Dependencies:** `codewhale-mcp-protocol`, `codewhale-tools`, `tokio`, +`serde_json`, `tracing` + +**Public API:** +```rust +pub struct McpServer { + // Tool registry +} + +impl McpServer { + pub fn new(tools: Vec>) -> Self; + pub async fn serve_stdio(self) -> Result<()>; + pub async fn serve_sse(self, addr: SocketAddr) -> Result<()>; +} +``` + +## 4. Migration path + +### Phase 1: Extract protocol crate (non-breaking) + +1. Move shared types from `crates/mcp/src/` to `crates/mcp-protocol/src/` +2. Re-export from `codewhale-mcp` for backward compatibility +3. Update `Cargo.toml` in `codewhale-mcp` to depend on `codewhale-mcp-protocol` + +### Phase 2: Split client and server (breaking for direct imports) + +1. Create `crates/mcp-client/` with client code +2. Create `crates/mcp-server/` with server code +3. Update `codewhale-tui` to depend on `codewhale-mcp-client` +4. Update `codewhale-cli` to depend on `codewhale-mcp-server` +5. Deprecate `codewhale-mcp` crate (re-exports from new crates) + +### Phase 3: Remove legacy crate + +1. Remove `crates/mcp/` after a deprecation cycle (one release) + +## 5. OAuth integration + +### 5.1 Token storage + +Tokens should be stored securely. Options (in priority order): +1. OS keychain via `keyring` crate (macOS Keychain, Windows Credential Manager, + Linux Secret Service) +2. Encrypted file in `~/.codewhale/mcp-credentials/` (fallback) +3. Environment variable `MCP_OAUTH_TOKEN_` + +### 5.2 OAuth flows + +Initial implementation supports: +- **Device Code Flow** (GitHub) — user opens a URL, enters a code +- **Client Credentials** — for service-to-service MCP servers + +Future (deferred): +- **PKCE** — for user-facing OAuth with redirect +- **Token refresh** — automatic refresh with refresh_token + +### 5.3 Configuration + +```toml +# ~/.codewhale/config.toml +[mcp.servers.github] +command = "npx" +args = ["-y", "@modelcontextprotocol/server-github"] + +[mcp.servers.github.oauth] +provider = "github" +client_id = "your-client-id" +scopes = ["repo", "read:org"] +``` + +## 6. Risks and unknowns + +| Risk | Mitigation | +|---|---| +| Crate proliferation | 3 small crates vs 1 medium crate; each has a clear purpose | +| Breaking internal imports | Phase 2 carries `codewhale-mcp` deprecation shim for one release | +| OAuth token security | OS keychain preferred; encrypted fallback with file permissions | +| Testing complexity | Each crate has its own test suite; integration tests remain in `crates/tui/tests/` | +| Dependency bloat | `oauth2` and `keyring` are optional features; consumers opt in | + +## 7. Out of scope (future RFCs) + +- MCP over HTTP/SSE transport (currently stdio only) +- MCP server discovery (currently explicit config) +- MCP tool result streaming (currently request-response) +- MCP server-side tool approval flows + +## Related + +- `crates/mcp/src/` — current implementation +- `crates/tui/src/mcp.rs` — TUI MCP integration +- `crates/tui/src/mcp_server.rs` — MCP stdio server +- `docs/MCP.md` — user-facing documentation +- Issue #2190 — this RFC diff --git a/integrations/feishu-bridge/package-lock.json b/integrations/feishu-bridge/package-lock.json index 9b00cd14..59a9402b 100644 --- a/integrations/feishu-bridge/package-lock.json +++ b/integrations/feishu-bridge/package-lock.json @@ -510,9 +510,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/integrations/feishu-bridge/package.json b/integrations/feishu-bridge/package.json index 9ee1fcc6..f67c33a7 100644 --- a/integrations/feishu-bridge/package.json +++ b/integrations/feishu-bridge/package.json @@ -15,7 +15,8 @@ "@larksuiteoapi/node-sdk": "^1.52.0" }, "overrides": { - "axios": "^1.16.1" + "axios": "^1.16.1", + "qs": ">=6.15.2" }, "engines": { "node": ">=18" diff --git a/scripts/release/install.bat b/scripts/release/install.bat new file mode 100644 index 00000000..69019415 --- /dev/null +++ b/scripts/release/install.bat @@ -0,0 +1,38 @@ +@echo off +setlocal enabledelayedexpansion +:: CodeWhale Windows installer +:: Copies codewhale.exe and codewhale-tui.exe to %USERPROFILE%\bin + +set "BIN_DIR=%USERPROFILE%\bin" +set "SCRIPT_DIR=%~dp0" + +if not exist "%BIN_DIR%" mkdir "%BIN_DIR%" + +echo Installing codewhale to %BIN_DIR%... + +copy /Y "%SCRIPT_DIR%codewhale.exe" "%BIN_DIR%\codewhale.exe" >nul +if %ERRORLEVEL% neq 0 ( + echo ERROR: Failed to copy codewhale.exe + exit /b 1 +) + +copy /Y "%SCRIPT_DIR%codewhale-tui.exe" "%BIN_DIR%\codewhale-tui.exe" >nul +if %ERRORLEVEL% neq 0 ( + echo ERROR: Failed to copy codewhale-tui.exe + exit /b 1 +) + +echo. +echo Done. Both binaries installed to %BIN_DIR%. +echo. +echo Add %BIN_DIR% to your PATH: +echo 1. Open Start, search "environment variables" +echo 2. Click "Environment Variables..." +echo 3. Under "User variables", select "Path" and click "Edit" +echo 4. Click "New" and add: %BIN_DIR% +echo 5. Click OK, then restart your terminal +echo. +echo Or run this in an admin PowerShell: +echo [Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path', 'User') + ';%BIN_DIR%', 'User') +echo. +echo Then run: codewhale diff --git a/scripts/release/install.sh b/scripts/release/install.sh new file mode 100644 index 00000000..7841c76f --- /dev/null +++ b/scripts/release/install.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail +# CodeWhale Unix installer +# Copies codewhale and codewhale-tui to ~/.local/bin (or $PREFIX/bin) + +PREFIX="${PREFIX:-$HOME/.local}" +BIN_DIR="${PREFIX}/bin" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +mkdir -p "$BIN_DIR" + +echo "Installing codewhale to $BIN_DIR ..." + +for bin in codewhale codewhale-tui; do + src="$SCRIPT_DIR/$bin" + dst="$BIN_DIR/$bin" + if [[ ! -f "$src" ]]; then + echo "ERROR: $src not found in archive" + exit 1 + fi + cp "$src" "$dst" + chmod +x "$dst" + echo " $dst" +done + +echo "" +echo "Done. Both binaries installed to $BIN_DIR." + +# Check if BIN_DIR is on PATH +if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then + echo "" + echo "Add $BIN_DIR to your PATH:" + echo "" + SHELL_NAME="$(basename "${SHELL:-$SHELL}")" + case "$SHELL_NAME" in + zsh) RC="$HOME/.zshrc" ;; + bash) RC="$HOME/.bashrc" ;; + fish) RC="$HOME/.config/fish/config.fish" ;; + *) RC="your shell profile" ;; + esac + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> $RC" + echo " source $RC" +fi + +echo "" +echo "Then run: codewhale" diff --git a/web/components/install-binary.tsx b/web/components/install-binary.tsx index 0e36afaa..16bad1b5 100644 --- a/web/components/install-binary.tsx +++ b/web/components/install-binary.tsx @@ -8,22 +8,30 @@ type Arch = "macos-arm64" | "macos-x64" | "linux-x64" | "linux-arm64" | "windows const SNIPPETS: Record = { "macos-arm64": `curl -fsSL -o codewhale \\ https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-macos-arm64 -chmod +x codewhale -xattr -d com.apple.quarantine codewhale 2>/dev/null || true -sudo mv codewhale /usr/local/bin/`, +curl -fsSL -o codewhale-tui \\ + https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-tui-macos-arm64 +chmod +x codewhale codewhale-tui +xattr -d com.apple.quarantine codewhale codewhale-tui 2>/dev/null || true +sudo mv codewhale codewhale-tui /usr/local/bin/`, "macos-x64": `curl -fsSL -o codewhale \\ https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-macos-x64 -chmod +x codewhale -xattr -d com.apple.quarantine codewhale 2>/dev/null || true -sudo mv codewhale /usr/local/bin/`, +curl -fsSL -o codewhale-tui \\ + https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-tui-macos-x64 +chmod +x codewhale codewhale-tui +xattr -d com.apple.quarantine codewhale codewhale-tui 2>/dev/null || true +sudo mv codewhale codewhale-tui /usr/local/bin/`, "linux-x64": `curl -fsSL -o codewhale \\ https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-linux-x64 -chmod +x codewhale -sudo mv codewhale /usr/local/bin/`, +curl -fsSL -o codewhale-tui \\ + https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-tui-linux-x64 +chmod +x codewhale codewhale-tui +sudo mv codewhale codewhale-tui /usr/local/bin/`, "linux-arm64": `curl -fsSL -o codewhale \\ https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-linux-arm64 -chmod +x codewhale -sudo mv codewhale /usr/local/bin/`, +curl -fsSL -o codewhale-tui \\ + https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-tui-linux-arm64 +chmod +x codewhale codewhale-tui +sudo mv codewhale codewhale-tui /usr/local/bin/`, "windows-x64": `# PowerShell $ErrorActionPreference = "Stop" $dest = "$Env:USERPROFILE\\bin" @@ -32,6 +40,9 @@ New-Item -ItemType Directory -Force $dest | Out-Null Invoke-WebRequest \` -Uri https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-windows-x64.exe \` -OutFile "$dest\\codewhale.exe" +Invoke-WebRequest \` + -Uri https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-tui-windows-x64.exe \` + -OutFile "$dest\\codewhale-tui.exe" $Env:Path = "$dest;$Env:Path"`, }; @@ -46,7 +57,8 @@ sha256sum -c codewhale-artifacts-sha256.txt --ignore-missing`, "linux-arm64": `curl -fsSL -O https://github.com/Hmbown/CodeWhale/releases/latest/download/codewhale-artifacts-sha256.txt sha256sum -c codewhale-artifacts-sha256.txt --ignore-missing`, "windows-x64": `# PowerShell -Get-FileHash "$Env:USERPROFILE\\bin\\codewhale.exe" -Algorithm SHA256`, +Get-FileHash "$Env:USERPROFILE\\bin\\codewhale.exe" -Algorithm SHA256 +Get-FileHash "$Env:USERPROFILE\\bin\\codewhale-tui.exe" -Algorithm SHA256`, }; const LABELS: Record = { @@ -103,4 +115,4 @@ export function InstallBinary({ copyLabel, copiedLabel, verifyHeading = "Verify ); -} +} \ No newline at end of file diff --git a/web/components/install-download-tile.tsx b/web/components/install-download-tile.tsx new file mode 100644 index 00000000..065b10a1 --- /dev/null +++ b/web/components/install-download-tile.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Arch = "macos-arm64" | "macos-x64" | "linux-x64" | "linux-arm64" | "windows-x64"; + +const BASE = + "https://github.com/Hmbown/CodeWhale/releases/latest/download"; + +const ASSETS: Record = { + "macos-arm64": { + zip: `${BASE}/codewhale-macos-arm64.zip`, + sha: `${BASE}/codewhale-artifacts-sha256.txt`, + }, + "macos-x64": { + zip: `${BASE}/codewhale-macos-x64.zip`, + sha: `${BASE}/codewhale-artifacts-sha256.txt`, + }, + "linux-x64": { + zip: `${BASE}/codewhale-linux-x64.zip`, + sha: `${BASE}/codewhale-artifacts-sha256.txt`, + }, + "linux-arm64": { + zip: `${BASE}/codewhale-linux-arm64.zip`, + sha: `${BASE}/codewhale-artifacts-sha256.txt`, + }, + "windows-x64": { + zip: `${BASE}/codewhale-windows-x64.zip`, + sha: `${BASE}/codewhale-artifacts-sha256.txt`, + }, +}; + +const LABELS: Record = { + "macos-arm64": "macOS · Apple Silicon", + "macos-x64": "macOS · Intel", + "linux-x64": "Linux · x64", + "linux-arm64": "Linux · arm64", + "windows-x64": "Windows · x64", +}; + +function detect(): Arch { + if (typeof navigator === "undefined") return "macos-arm64"; + const ua = navigator.userAgent.toLowerCase(); + if (ua.includes("win")) return "windows-x64"; + if (ua.includes("linux")) { + if (ua.includes("aarch64") || ua.includes("arm64")) return "linux-arm64"; + return "linux-x64"; + } + return "macos-arm64"; +} + +interface Props { + heading: string; + downloadLabel: string; + sha256Label: string; + mirrorHeading: string; + mirrorGhproxy: string; + mirrorJsdelivr: string; + offlineCallout: string; +} + +export function InstallDownloadTile({ + heading, + downloadLabel, + sha256Label, + mirrorHeading, + mirrorGhproxy, + mirrorJsdelivr, + offlineCallout, +}: Props) { + const [arch, setArch] = useState("macos-arm64"); + + useEffect(() => { + setArch(detect()); + }, []); + + const { zip, sha } = ASSETS[arch]; + const ghproxy = `https://ghproxy.com/${zip}`; + const jsdelivr = `https://cdn.jsdelivr.net/gh/Hmbown/CodeWhale@latest/${zip.split("/").pop()}`; + + return ( +
+ {/* Arch selector tabs */} +
+ {(Object.keys(LABELS) as Arch[]).map((a, i) => ( + + ))} +
+ +

{heading}

+ + {/* Download button */} + + + {/* China mirror links */} +
+
{mirrorHeading}
+
+ + {mirrorGhproxy} + + + {/* jsdelivr doesn't directly proxy GitHub Release assets; link to the release page instead */} + + {mirrorJsdelivr} + + +
+
+ + {/* Offline callout */} +
+ 💡 + {offlineCallout} +
+
+ ); +}