mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
fix(sandbox): protect git hooks, git config and .codewhale from sandboxed writes
Security review E5 (codewhale-ops/security/PRE-LAUNCH-REVIEW-20261027.md; founder-accepted D13, 2026-10-27). In workspace-write mode a sandboxed command could write .git/hooks/* or .git/config (for example core.hooksPath), which later runs outside the sandbox on the next git operation; and .codewhale/ was protected only if it already existed. - Every writable root now gets `.git/hooks` and `.git/config` as read-only subpaths when it holds a git dir; a linked worktree's gitdir/commondir roots get their own `hooks` and `config`. - Workspace roots (cwd and configured writable roots) always protect `.codewhale/`, even before it exists. Seatbelt rules work on nonexistent paths; their parameter now resolves through the canonical parent so /var vs /private/var cannot slip past. - bwrap can only bind existing paths: prepare_bwrap creates the workspace's `.codewhale/` first, and read-only exceptions may now be files (`.git/config`), not only directories. - The seatbelt policy test matched the old single-exclusion text exactly; it now checks the root-plus-first-exclusion prefix. Verified with an uncommitted probe that runs one sandboxed shell script against a fresh repo (hook write, config append, .codewhale creation, ordinary file write, git commit), before vs after: - macOS Seatbelt (local, build lock): before all WROTE; after hooks, config and .codewhale DENIED, file write and git commit OK, no hook left behind. codewhale-sandbox tests: 104 passed, 0 failed. - Linux bubblewrap (Boat vm20, Ubuntu 24.04): same before/after split. codewhale-sandbox tests: 100 passed, 0 failed. Logs: CW/artifacts/release-0103-gates-20261027/e5-mac.log, e5-vm20.log. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5129cef5c9
commit
0e9c5beba5
7 files changed
+37
-6
No files matched your search
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Sandboxed commands cannot plant git hooks or edit git config:** in workspace-write mode, `.git/hooks` and `.git/config` (and a linked worktree's shared `hooks` and `config`) are read-only to sandboxed commands, so a command cannot leave a hook or `core.hooksPath` that later runs outside the sandbox. The workspace's `.codewhale/` is protected even before it exists (on Linux it is created first, because bubblewrap can only protect existing paths). `git commit` and ordinary file writes are unaffected; `git config` writes and hook edits need an approved unsandboxed command.
|
||||
- **Interrupted session hint stays visible (#7095):** when a plain `codewhale` launch finds an in-flight session snapshot, the "Run `codewhale --continue` to resume it" hint now appears in the TUI's status line instead of being printed to the terminal just before the TUI covered it.
|
||||
- **Crate split S24a (internal):** the workshop byte-budget slot moves to `codewhale-runtime`, `AutoRouteDecisionEvidence` to `codewhale-models`, and cost-status session persistence to a tui `usage_persistence` module, so billing no longer reaches the session manager or model routing. No behavior change. (CW-6129)
|
||||
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one naming an existing thread, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
|
||||
|
||||
@@ -348,7 +348,7 @@ fn safe_mounts(writable_roots: &[WritableRoot]) -> (Vec<PathBuf>, Vec<PathBuf>)
|
||||
writable.insert(canonical_root.clone());
|
||||
|
||||
for exception in &root.read_only_subpaths {
|
||||
let Some(canonical_exception) = existing_directory(exception) else {
|
||||
let Some(canonical_exception) = exception.canonicalize().ok() else {
|
||||
continue;
|
||||
};
|
||||
if canonical_exception.starts_with(&canonical_root) {
|
||||
|
||||
@@ -885,6 +885,9 @@ impl SandboxManager {
|
||||
let writable_roots = if self.readable_paths.is_some() {
|
||||
vec![policy::WritableRoot::new(spec.cwd.clone())]
|
||||
} else {
|
||||
if matches!(spec.sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
|
||||
let _ = std::fs::create_dir(spec.cwd.join(".codewhale"));
|
||||
}
|
||||
spec.sandbox_policy.get_writable_roots(&spec.cwd)
|
||||
};
|
||||
let command = bwrap::build_bwrap_command_with_read_paths(
|
||||
|
||||
@@ -317,6 +317,7 @@ impl SandboxPolicy {
|
||||
..
|
||||
} => {
|
||||
let mut roots: Vec<PathBuf> = writable_roots.clone();
|
||||
let mut workspace_roots = roots.clone();
|
||||
|
||||
// Add the current working directory
|
||||
if let Ok(canonical_cwd) = cwd.canonicalize() {
|
||||
@@ -324,13 +325,17 @@ impl SandboxPolicy {
|
||||
} else {
|
||||
roots.push(cwd.to_path_buf());
|
||||
}
|
||||
workspace_roots.extend(roots.last().cloned());
|
||||
|
||||
// Git worktrees keep mutable metadata outside the worktree
|
||||
// directory. Allow only the gitdir and commondir derived from
|
||||
// a workspace `.git` pointer, preserving the workspace boundary
|
||||
// for all other external paths.
|
||||
let mut git_dir_roots = Vec::new();
|
||||
for root in roots.clone() {
|
||||
roots.extend(resolve_git_worktree_writable_roots(&root));
|
||||
let git_dirs = resolve_git_worktree_writable_roots(&root);
|
||||
git_dir_roots.extend(git_dirs.iter().cloned());
|
||||
roots.extend(git_dirs);
|
||||
}
|
||||
|
||||
// Add /tmp unless excluded
|
||||
@@ -352,15 +357,28 @@ impl SandboxPolicy {
|
||||
.map(|root| {
|
||||
let mut read_only_subpaths = Vec::new();
|
||||
|
||||
// Protect .codewhale/ and .deepseek/ directories from modification
|
||||
// Protect .codewhale/ and .deepseek/ from modification, and
|
||||
// the git hooks and config that later run or configure
|
||||
// commands outside the sandbox. A workspace root protects
|
||||
// .codewhale/ even before it exists.
|
||||
let workspace = workspace_roots.contains(&root);
|
||||
let codewhale_dir = root.join(".codewhale");
|
||||
if codewhale_dir.is_dir() {
|
||||
if workspace || codewhale_dir.is_dir() {
|
||||
read_only_subpaths.push(codewhale_dir);
|
||||
}
|
||||
let deepseek_dir = root.join(".deepseek");
|
||||
if deepseek_dir.is_dir() {
|
||||
read_only_subpaths.push(deepseek_dir);
|
||||
}
|
||||
let git_dir = if git_dir_roots.contains(&root) {
|
||||
root.clone()
|
||||
} else {
|
||||
root.join(".git")
|
||||
};
|
||||
if git_dir.is_dir() {
|
||||
read_only_subpaths.push(git_dir.join("hooks"));
|
||||
read_only_subpaths.push(git_dir.join("config"));
|
||||
}
|
||||
|
||||
WritableRoot {
|
||||
root,
|
||||
|
||||
@@ -479,7 +479,15 @@ fn generate_params(policy: &SandboxPolicy, cwd: &Path) -> Vec<(String, PathBuf)>
|
||||
|
||||
// Add parameters for read-only subpaths
|
||||
for (subpath_index, subpath) in root.read_only_subpaths.iter().enumerate() {
|
||||
let canonical_subpath = subpath.canonicalize().unwrap_or_else(|_| subpath.clone());
|
||||
let canonical_subpath = subpath.canonicalize().unwrap_or_else(|_| {
|
||||
match (
|
||||
subpath.parent().map(Path::canonicalize),
|
||||
subpath.file_name(),
|
||||
) {
|
||||
(Some(Ok(parent)), Some(name)) => parent.join(name),
|
||||
_ => subpath.clone(),
|
||||
}
|
||||
});
|
||||
params.push((
|
||||
format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"),
|
||||
canonical_subpath,
|
||||
|
||||
@@ -39,7 +39,7 @@ fn file_provider_extensions_intersect_each_workspace_root_and_exclusions() {
|
||||
|
||||
for root_index in 0..=1 {
|
||||
let expected = format!(
|
||||
r#"(require-all (extension "com.apple.app-sandbox.read-write") (subpath (param "WRITABLE_ROOT_{root_index}")) (require-not (subpath (param "WRITABLE_ROOT_{root_index}_RO_0"))))"#
|
||||
r#"(require-all (extension "com.apple.app-sandbox.read-write") (subpath (param "WRITABLE_ROOT_{root_index}")) (require-not (subpath (param "WRITABLE_ROOT_{root_index}_RO_0")))"#
|
||||
);
|
||||
assert!(
|
||||
workspace_write.contains(&expected),
|
||||
|
||||
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Sandboxed commands cannot plant git hooks or edit git config:** in workspace-write mode, `.git/hooks` and `.git/config` (and a linked worktree's shared `hooks` and `config`) are read-only to sandboxed commands, so a command cannot leave a hook or `core.hooksPath` that later runs outside the sandbox. The workspace's `.codewhale/` is protected even before it exists (on Linux it is created first, because bubblewrap can only protect existing paths). `git commit` and ordinary file writes are unaffected; `git config` writes and hook edits need an approved unsandboxed command.
|
||||
- **Interrupted session hint stays visible (#7095):** when a plain `codewhale` launch finds an in-flight session snapshot, the "Run `codewhale --continue` to resume it" hint now appears in the TUI's status line instead of being printed to the terminal just before the TUI covered it.
|
||||
- **Crate split S24a (internal):** the workshop byte-budget slot moves to `codewhale-runtime`, `AutoRouteDecisionEvidence` to `codewhale-models`, and cost-status session persistence to a tui `usage_persistence` module, so billing no longer reaches the session manager or model routing. No behavior change. (CW-6129)
|
||||
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one naming an existing thread, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
|
||||
|
||||
Reference in new issue
Block a user