v0.8.46: release archives, sandbox depth, quick fixes, web install, docs

* docs: v0.8.46 CHANGELOG — platform archives, palette, sub-agents, sandbox, web install, search fixes

Closes #2188

* feat(v0.8.46): quick fixes — palette, model picker Esc, sub-agent sidebar, shell chip, model name casing, CVE bump (#2212)

* fix: bump qs to >=6.15.2 for CVE-2026-8723

Add qs override in feishu-bridge package.json to force transitive
dependency resolution to >=6.15.2, addressing CVE-2026-8723.

Refs: #2198

* fix: Esc in model picker applies last-highlighted choice

Previously Esc reverted to the initial model when the user hadn't
moved the selection. Now Esc always applies the currently highlighted
model and thinking-effort tier, making Esc consistent with Enter.

Also updates the picker footer hint from 'Esc cancel' to 'Esc apply'.

Refs: #2196

* feat: show ' shell running' chip in TUI footer

Adds a footer_shell_chip function that displays a ' shell running'
status chip in the footer's right cluster whenever a foreground shell
command is active via exec_shell. The chip is always visible regardless
of user-configured status items.

Refs: #2194

* feat: auto-collapse finished sub-agents in sidebar

When a sub-agent completes (status = 'done'), its detail lines
(id, steps, duration, progress) are now hidden in the sidebar agents
panel. Only the summary label line is shown, keeping the sidebar
compact. Running agents still show full detail.

Refs: #2195

* feat: refresh Whale dark palette for better contrast

Improve contrast and layer separation in the Whale dark theme:
- Deepen base background for more depth (10,17,32)
- Lighten panel (22,34,56) for clearer distinction from bg
- Lighten elevated surface (36,52,78) for better elevation
- Lighten selection (48,68,100) for clearer selected state
- Boost text hint (138,150,174) and dim (118,130,156) readability
- Brighter border (52,88,145) for better edge definition
- Update tool surface colors for consistency

Refs: #2197

* fix: preserve model name casing in normalize_model_name_for_provider

When the user enters a model name like 'DeepSeek-V4-Flash', the
normalizer was lowercasing it to 'deepseek-v4-flash' via the
canonical_official_deepseek_model_id function. Now the normalizer
preserves the caller's casing when the input already matches a known
model id case-insensitively. Compact aliases like 'deepseek-v4pro'
are still rewritten to 'deepseek-v4-pro'.

Refs: #2109

* feat(web): install download tile with arch detection, SHA256, China mirrors + companion binary fix (#2213)

* fix(web): download both codewhale and codewhale-tui binaries in install snippets

The SNIPPETS map only fetched one binary per platform, causing the
dispatcher to fail with MISSING_COMPANION_BINARY. Every arch now
downloads both codewhale AND codewhale-tui side-by-side.

- macOS/Linux: added second curl + combined chmod/xattr/mv for tui
- Windows: added second Invoke-WebRequest for codewhale-tui.exe
- VERIFY: PowerShell now hashes both binaries; Unix --ignore-missing
  covers all present binaries in a single sha256sum pass

* feat(web): add install download tile with arch detection, SHA256, and China mirrors (#2192)

* feat(sandbox/linux): process hardening — PR_SET_DUMPABLE, NO_NEW_PRIVS, RLIMIT_CORE (#2214)

* feat(sandbox/linux): add process hardening module — PR_SET_DUMPABLE, NO_NEW_PRIVS, RLIMIT_CORE (#2183)

* feat(sandbox/linux): seccomp filter + bwrap passthrough

- seccomp: BPF filter whitelisting safe syscalls, denying ptrace/mount/kexec
  and other dangerous syscalls. Uses raw BPF instructions via libc prctl to
  avoid external dependencies (#2182).
- bwrap: optional bubblewrap passthrough when /usr/bin/bwrap is present
  and [sandbox] prefer_bwrap=true in config. Creates read-only rootfs with
  write access limited to the working directory (#2184).
- landlock detect_denial extended to recognize seccomp SIGSYS/"Bad system
  call" patterns alongside existing Landlock EACCES/EPERM detection.
- SandboxManager gains prefer_bwrap field; set_prefer_bwrap on ShellManager.
- EngineConfig gains prefer_bwrap field, wired through main/ui/runtime_threads.
- Diagnostics now reports bwrap_available and cgroup_version.
- config.example.toml documents the prefer_bwrap key.

Pre-existing clippy fixes picked up in the same build:
- collapsible_if in ui.rs version-check
- cmp_owned in goal.rs test
- consecutive str::replace in normalize_auth_mode

Closes #2182, closes #2184

* docs: add cross-links to issue and PR templates in CONTRIBUTING.md (#2215)

- Link .github/ISSUE_TEMPLATE/bug_report.md and feature_request.md from
  the Reporting Issues section
- Link .github/PULL_REQUEST_TEMPLATE.md from the Pull Request Guidelines
  section

* feat(release): bundle platform archives with install scripts (#2216)

- Add bundle job to release workflow that creates per-platform archives
  (tar.gz for Linux/macOS, .zip for Windows) containing both codewhale
  and codewhale-tui binaries plus install scripts
- Create install.bat (Windows) — copies binaries to %USERPROFILE%\bin
- Create install.sh (Unix) — copies binaries to ~/.local/bin
- Windows gets a portable .zip variant without install script
- Release notes updated to promote archives as primary download method
- Individual binaries retained for npm wrapper and scripting

Closes #2193

* fix(web_search): fall back to DuckDuckGo when Bing returns zero results (#2130)

When the configured search provider is Bing and the query returns zero
results (common for technical/compound queries), fall through to the
DuckDuckGo path instead of reporting empty. A provenance message is
surfaced: "Bing returned no results; used DuckDuckGo fallback".

Also adds Security and Code of Conduct cross-links to CONTRIBUTING.md
per the sub-agent renovation (#2203).

* docs: SANDBOX.md threat model + RFCs for persistence and MCP + SandboxExecutor trait

- docs/SANDBOX.md: complete threat model describing each platform's sandbox
  (Seatbelt, Landlock, seccomp, process hardening, bwrap, Windows v1).
  Covers defense-in-depth layering, config keys, denial detection, limitations.
- docs/rfcs/2189-persistence-sqlite.md: RFC for SQLite migration (drafted by sub-agent)
- docs/rfcs/2190-mcp-modularization.md: RFC for MCP crate split into
  protocol/client/server with OAuth support
- crates/tui/src/sandbox/policy.rs: SandboxExecutor trait definition and
  SafetyLevel→SandboxPolicyBehavior mapping function with tests

Closes #2180, closes #2186, closes #2189, closes #2190

* feat: sandbox parity tests + remove sub-agent 100-turn cap

- Add sandbox parity tests covering platform detection, denial patterns,
  bwrap preference, and policy consistency across modes (#2187)
- Remove arbitrary 100-turn sub-agent cap: DEFAULT_MAX_STEPS changed
  from 100 to u32::MAX. Sub-agents now run until they produce a final
  text response, are cancelled by the parent, or hit a configured
  explicit budget (#2034)

Closes #2187, closes #2034
This commit is contained in:
Hunter Bown
2026-05-26 09:52:22 -05:00
committed by GitHub
parent a953cc2be8
commit 1763261503
35 changed files with 2178 additions and 94 deletions
+130 -15
View File
@@ -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-<platform>.tar.gz
cd codewhale-<platform>
./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
+52 -8
View File
@@ -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 <name>`, `provider = "<name>"` in
`~/.codewhale/config.toml`, or `CODEWHALE_PROVIDER=<name>`.
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
+20 -2
View File
@@ -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
+15
View File
@@ -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.
+1 -1
View File
@@ -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"
));
}
+19 -2
View File
@@ -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<String> {
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<String>,
/// Optional API key for the external sandbox backend (sent as Bearer token).
pub sandbox_api_key: Option<String>,
/// 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<bool>,
pub managed_config_path: Option<String>,
pub requirements_path: Option<String>,
pub max_subagents: Option<usize>,
@@ -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),
+5
View File
@@ -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,
}
}
}
+6
View File
@@ -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(),
+13 -13
View File
@@ -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;
+1
View File
@@ -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(),
+131
View File
@@ -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 <cwd> <cwd> \
//! --chdir <cwd> \
//! --unshare-all \
//! -- <program> <args>
//! ```
//!
//! 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<String>` representing the full bwrap invocation.
#[cfg(target_os = "linux")]
pub fn build_bwrap_command(cwd: &std::path::Path, program: &str, args: &[String]) -> Vec<String> {
let mut cmd: Vec<String> = 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");
}
}
+18 -4
View File
@@ -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
+166 -17
View File
@@ -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<SandboxType>,
/// 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: <cmd> denied <operation>" 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);
}
}
}
+82
View File
@@ -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<ExecEnv>;
/// 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 {
+137
View File
@@ -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/<pid>/ 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();
}
}
+405
View File
@@ -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::<sock_filter>::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"));
}
}
}
+40
View File
@@ -28,6 +28,8 @@ struct DiagnosticsOutput {
git_error: Option<String>,
sandbox_available: bool,
sandbox_type: Option<String>,
bwrap_available: bool,
cgroup_version: Option<u8>,
rustc_version: Option<String>,
cargo_version: Option<String>,
/// 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<u8> {
#[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<String> {
run_command(program, args, cwd).into_success()
}
+9
View File
@@ -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) {
+6 -1
View File
@@ -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
+17 -2
View File
@@ -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
+11
View File
@@ -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<Span<'static>> = 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<Span<'s
})
.unwrap_or_default();
let shell_spans =
crate::tui::widgets::footer_shell_chip(active_foreground_shell_running(app));
let parts: Vec<&Vec<Span<'static>>> = [
&coherence_spans,
&agents_spans,
@@ -604,6 +614,7 @@ pub(crate) fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'s
&prefix_spans,
&cache_spans,
&cost_spans,
&shell_spans,
]
.iter()
.filter(|spans| !spans.is_empty())
+11 -7
View File
@@ -3,8 +3,8 @@
//!
//! Two side-by-side panes — Models on the left, Thinking effort on the
//! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies
//! both and closes the modal. Esc closes immediately when nothing moved; after
//! a selection move it keeps the highlighted choice.
//! both and closes the modal. Esc applies the last-highlighted choice and
//! closes.
//!
//! The effort pane intentionally only exposes `Off / High / Max`. Per
//! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model),
@@ -274,8 +274,7 @@ impl ModalView for ModelPickerView {
fn handle_key(&mut self, key: KeyEvent) -> 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]
+6
View File
@@ -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;
}
+5 -4
View File
@@ -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 {
+13
View File
@@ -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<Span<'static>> {
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
+2 -1
View File
@@ -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;
+271
View File
@@ -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/<pid>/` 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/<pid>/mem` (already blocked
by `PR_SET_DUMPABLE=0` making `/proc/<pid>/` 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
+86
View File
@@ -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.
+226
View File
@@ -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_<server>_<tool>` 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<Self>;
pub async fn list_tools(&self) -> Result<Vec<ToolSchema>>;
pub async fn call_tool(&self, name: &str, args: Value) -> Result<Value>;
pub async fn disconnect(self);
}
pub struct McpClientConfig {
pub command: String, // e.g., "npx", "python"
pub args: Vec<String>, // e.g., ["-y", "@modelcontextprotocol/server-github"]
pub env: HashMap<String, String>,
pub oauth: Option<OAuthConfig>,
pub timeout: Duration,
}
pub struct OAuthConfig {
pub provider: OAuthProvider,
pub client_id: String,
pub scopes: Vec<String>,
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<Arc<dyn ToolSpec>>) -> 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_<PROVIDER>`
### 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
+3 -3
View File
@@ -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"
+2 -1
View File
@@ -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"
+38
View File
@@ -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
+46
View File
@@ -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"
+23 -11
View File
@@ -8,22 +8,30 @@ type Arch = "macos-arm64" | "macos-x64" | "linux-x64" | "linux-arm64" | "windows
const SNIPPETS: Record<Arch, string> = {
"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<Arch, string> = {
+160
View File
@@ -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<Arch, { zip: string; sha: string }> = {
"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<Arch, string> = {
"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<Arch>("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 (
<div>
{/* Arch selector tabs */}
<div className="flex flex-wrap gap-0 mb-6 hairline-t hairline-b hairline-l hairline-r">
{(Object.keys(LABELS) as Arch[]).map((a, i) => (
<button
key={a}
onClick={() => setArch(a)}
className={`px-3 py-1.5 font-mono text-[0.7rem] tracking-wider transition-colors ${
i > 0 ? "hairline-l" : ""
} ${arch === a ? "bg-ink text-paper" : "bg-paper hover:bg-paper-deep"}`}
>
{LABELS[a]}
</button>
))}
</div>
<h2 className="font-display text-3xl mb-2">{heading}</h2>
{/* Download button */}
<div className="flex flex-wrap items-center gap-4 mt-6 mb-4">
<a
href={zip}
className="inline-flex items-center gap-2 px-5 py-3 bg-ink text-paper font-mono text-sm tracking-wide hover:bg-indigo transition-colors"
download
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 1v9M4 7l4 4 4-4M2 12v2h12v-2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{downloadLabel} (.zip)
</a>
<a
href={sha}
className="font-mono text-[0.7rem] uppercase tracking-wider text-ink-mute hover:text-indigo transition-colors"
>
{sha256Label}
</a>
</div>
{/* China mirror links */}
<div className="mt-6">
<div className="eyebrow mb-2">{mirrorHeading}</div>
<div className="flex flex-wrap gap-3">
<a
href={ghproxy}
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-mono hairline-t hairline-b hairline-l hairline-r hover:bg-paper-deep transition-colors"
rel="noopener noreferrer"
target="_blank"
>
{mirrorGhproxy}
</a>
<span className="text-xs text-ink-mute self-center">
{/* jsdelivr doesn't directly proxy GitHub Release assets; link to the release page instead */}
<a
href={`https://github.com/Hmbown/CodeWhale/releases/latest`}
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-mono hairline-t hairline-b hairline-l hairline-r hover:bg-paper-deep transition-colors"
rel="noopener noreferrer"
target="_blank"
>
{mirrorJsdelivr}
</a>
</span>
</div>
</div>
{/* Offline callout */}
<div className="mt-6 px-4 py-3 bg-indigo-pale text-sm leading-relaxed">
<span className="font-display text-indigo mr-2">💡</span>
{offlineCallout}
</div>
</div>
);
}