fix(security): refuse project harness writes through linked state paths

Harness refine and remove write model-authored text to state.json and
JOURNAL.md under the workspace's .codewhale/harness. Nothing checked whether
.codewhale, the harness directory, the state file or the journal was a link,
so a workspace that ships one could send those writes outside the workspace.
The write path is now refused when any component from the workspace down is a
link, before a directory is created through it. Reads are unchanged.

Tests (Rust 1.99, macOS arm64):
- codewhale-runtime --lib continual_harness: test result: ok. 8 passed; 0 failed
- codewhale-tui --lib harness: test result: ok. 18 passed; 0 failed
- Control: with the check aimed at the wrong directory (so it never saw the
  link), the new test failed at its "nothing created outside" assertion.
- Clippy (CI policy) on codewhale-runtime, all targets: clean.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-10-01 12:37:03 -07:00
1 parent 0475a4875c
commit 2c95b7de75
3 files changed
+78 -7

No files matched your search

+2 -2
View File
@@ -87,8 +87,8 @@ quieter, and Fleet runs can be checked before they spend anything.
`remember`.
- More file and network paths are confined. A task or automation created by a
session without full shell access no longer inherits the host's shell
default. The commit planner and oversized-paste backup do not read or write
through links that leave the workspace. Audit and approval logs are created
default. The commit planner, oversized-paste backup and project harness
notes do not read or write through links that leave the workspace. Audit and approval logs are created
owner-only and are not opened through a link. Lane ids must be plain names.
A session file is refused when it records a different session id than its
name. The updater does not follow a redirect from HTTPS to plain HTTP and
+74 -3
View File
@@ -230,10 +230,54 @@ pub fn journal_path(state_path: &Path) -> PathBuf {
fn state_path_for_write(workspace: &Path) -> Result<PathBuf> {
let existing = state_path_for_read(workspace)?;
if existing.is_file() {
return Ok(existing);
let path = if existing.is_file() {
existing
} else {
// Check the parents that already exist before creating anything
// through them.
let planned = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf())
.join(codewhale_config::CODEWHALE_APP_DIR)
.join("harness")
.join("state.json");
reject_linked_state_path(workspace, &planned)?;
codewhale_config::ensure_project_state_dir(workspace, "harness")?.join("state.json")
};
reject_linked_state_path(workspace, &path)?;
reject_linked_state_path(workspace, &journal_path(&path))?;
Ok(path)
}
/// Harness writes carry model-authored text. Refuse them when the state
/// directory, the state file or the journal is a link, so a workspace that
/// ships a linked `.codewhale` cannot send them outside the workspace.
fn reject_linked_state_path(workspace: &Path, path: &Path) -> Result<()> {
let workspace = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let Ok(relative) = path.strip_prefix(&workspace) else {
bail!(
"continual harness state {} is outside the workspace",
path.display()
);
};
let mut current = workspace;
for component in relative.components() {
current.push(component);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => bail!(
"continual harness state path {} is a link; refusing to write through it",
current.display()
),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
Err(error) => {
return Err(error).with_context(|| format!("inspect {}", current.display()));
}
}
}
Ok(codewhale_config::ensure_project_state_dir(workspace, "harness")?.join("state.json"))
Ok(())
}
fn load_state(path: &Path) -> Result<HarnessState> {
@@ -515,4 +559,31 @@ mod tests {
"{journal}"
);
}
#[cfg(unix)]
#[test]
fn refinement_is_refused_through_linked_state_paths() {
use std::os::unix::fs::symlink;
// A linked `.codewhale` directory.
let outside = tempdir().expect("outside");
let tmp = tempdir().expect("tempdir");
symlink(outside.path(), tmp.path().join(".codewhale")).expect("link");
assert!(refine(tmp.path(), refinement(HarnessEntryKind::PromptNote)).is_err());
assert!(fs::read_dir(outside.path()).unwrap().next().is_none());
// A linked journal beside a real state file.
let tmp = tempdir().expect("tempdir");
refine(tmp.path(), refinement(HarnessEntryKind::PromptNote)).expect("first");
let state = state_path_for_read(tmp.path()).expect("state path");
let journal = journal_path(&state);
let target = outside.path().join("target.md");
fs::write(&target, "keep").expect("write");
fs::remove_file(&journal).expect("remove journal");
symlink(&target, &journal).expect("link");
let mut other = refinement(HarnessEntryKind::SkillHint);
other.title = "Another title".to_string();
assert!(refine(tmp.path(), other).is_err());
assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
}
}
+2 -2
View File
@@ -87,8 +87,8 @@ quieter, and Fleet runs can be checked before they spend anything.
`remember`.
- More file and network paths are confined. A task or automation created by a
session without full shell access no longer inherits the host's shell
default. The commit planner and oversized-paste backup do not read or write
through links that leave the workspace. Audit and approval logs are created
default. The commit planner, oversized-paste backup and project harness
notes do not read or write through links that leave the workspace. Audit and approval logs are created
owner-only and are not opened through a link. Lane ids must be plain names.
A session file is refused when it records a different session id than its
name. The updater does not follow a redirect from HTTPS to plain HTTP and