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:
+130
-15
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+14
-14
@@ -4,17 +4,17 @@ use ratatui::style::Color;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
|
||||
// v0.8.45 Whale dark palette — refreshed ocean/navy identity.
|
||||
pub const WHALE_BG_RGB: (u8, u8, u8) = (13, 21, 37); // #0D1525 Deep Navy
|
||||
pub const WHALE_PANEL_RGB: (u8, u8, u8) = (19, 29, 48); // #131D30
|
||||
pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (26, 40, 64); // #1A2840
|
||||
pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (30, 50, 82); // #1E3252
|
||||
// v0.8.46 Whale dark palette — improved contrast and layer separation.
|
||||
pub const WHALE_BG_RGB: (u8, u8, u8) = (10, 17, 32); // #0A1120 Deep Navy
|
||||
pub const WHALE_PANEL_RGB: (u8, u8, u8) = (22, 34, 56); // #162238
|
||||
pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (36, 52, 78); // #24344E
|
||||
pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (48, 68, 100); // #304464
|
||||
pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); // #F6F2E8 Whale Ivory
|
||||
pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 224, 234); // #D9E0EA
|
||||
pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (169, 180, 199); // #A9B4C7 Mist Gray
|
||||
pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (122, 134, 158); // #7A869E
|
||||
pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 150, 174); // #8A96AE
|
||||
#[allow(dead_code)]
|
||||
pub const WHALE_TEXT_DIM_RGB: (u8, u8, u8) = (107, 120, 146); // #6B7892
|
||||
pub const WHALE_TEXT_DIM_RGB: (u8, u8, u8) = (118, 130, 156); // #76829C
|
||||
pub const WHALE_ACCENT_PRIMARY_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold
|
||||
pub const WHALE_ACCENT_SECONDARY_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam
|
||||
pub const WHALE_ACCENT_ACTION_RGB: (u8, u8, u8) = (255, 122, 89); // #FF7A59 Coral Spark
|
||||
@@ -26,10 +26,10 @@ pub const WHALE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 214, 222); // #FFD6DE Error
|
||||
pub const WHALE_WARNING_RGB: (u8, u8, u8) = (240, 160, 48); // #F0A030
|
||||
pub const WHALE_SUCCESS_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam
|
||||
pub const WHALE_INFO_RGB: (u8, u8, u8) = (106, 174, 242); // #6AAEF2 Sky
|
||||
pub const WHALE_BORDER_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F
|
||||
pub const WHALE_BORDER_RGB: (u8, u8, u8) = (52, 88, 145); // #345891
|
||||
pub const WHALE_REASONING_TEXT_RGB: (u8, u8, u8) = (224, 153, 72); // #E09948
|
||||
pub const WHALE_REASONING_SURFACE_RGB: (u8, u8, u8) = (42, 34, 24); // #2A2218
|
||||
pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (20, 30, 42); // #141E2A
|
||||
pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (24, 36, 52); // #182434
|
||||
pub const WHALE_DIFF_ADDED_RGB: (u8, u8, u8) = (87, 199, 133); // #57C785
|
||||
#[allow(dead_code)]
|
||||
pub const WHALE_DIFF_DELETED_RGB: (u8, u8, u8) = (255, 92, 122); // #FF5C7A Rose Red
|
||||
@@ -39,11 +39,11 @@ pub const WHALE_MODE_AGENT_RGB: (u8, u8, u8) = (80, 150, 255); // #5096FF
|
||||
pub const WHALE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 100, 100); // #FF6464
|
||||
pub const WHALE_MODE_PLAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold
|
||||
pub const WHALE_MODE_GOAL_RGB: (u8, u8, u8) = (100, 220, 160); // #64DCA0
|
||||
pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = (133, 184, 234); // #85B8EA
|
||||
pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = (192, 143, 153); // #C08F99
|
||||
pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = (140, 190, 238); // #8CBEEE
|
||||
pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = (198, 150, 160); // #C696A0
|
||||
pub const WHALE_TOOL_OUTPUT_RGB: (u8, u8, u8) = (194, 208, 224); // #C2D0E0
|
||||
pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (24, 34, 53); // #182235
|
||||
pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (31, 45, 69); // #1F2D45
|
||||
pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (28, 40, 62); // #1C283E
|
||||
pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (38, 54, 80); // #263650
|
||||
|
||||
// Backward-compatible aliases for existing call sites.
|
||||
pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = WHALE_ACCENT_PRIMARY_RGB;
|
||||
@@ -2060,4 +2060,4 @@ mod tests {
|
||||
let _ = ColorDepth::detect();
|
||||
let _ = adapt_color(DEEPSEEK_INK, ColorDepth::detect());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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> = {
|
||||
@@ -103,4 +115,4 @@ export function InstallBinary({ copyLabel, copiedLabel, verifyHeading = "Verify
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user