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:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 18:39:32 -07:00
1 parent 5129cef5c9
commit 0e9c5beba5
7 files changed
+37 -6

No files matched your search

+1
View File
@@ -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.
+1 -1
View File
@@ -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) {
+3
View File
@@ -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(
+21 -3
View File
@@ -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,
+9 -1
View File
@@ -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,
+1 -1
View File
@@ -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),
+1
View File
@@ -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.