fix: more workspace .codewhale writers go through confined or link-refusing paths

Workflow run reports and raw schema replies, Fleet worker logs, the Fleet
manager lock and worker log artifacts, saved and selected Fleet files, the
project hooks template, project MCP configuration writes and lane worktree
provisioning no longer create or write through a link below the workspace.
The restore after a failed config import replaces the file by rename.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-10-01 20:08:57 -07:00
1 parent d9d1763978
commit 19fe4d8225
10 files changed
+364 -69

No files matched your search

+3 -1
View File
@@ -1172,7 +1172,9 @@ fn rollback_import_target(
if let Some(backup_path) = backup_path {
let bytes = std::fs::read(backup_path)
.with_context(|| format!("reading pre-import backup {}", backup_path.display()))?;
std::fs::write(target, bytes)
// Replace the name by rename (owner-only), never write through a link
// that has appeared at it since the import wrote the file.
codewhale_config::persistence::atomic_write(target, &bytes)
.with_context(|| format!("restoring pre-import config {}", target.display()))?;
return Ok(());
}
+52
View File
@@ -27,6 +27,33 @@ pub struct ProvisionedWorktree {
pub branch: String,
}
/// Refuse `path` when an existing component between `root` and `path` is a
/// link. A path outside `root` (an explicit `--worktree-path`) is the caller's
/// own choice and is not checked.
fn reject_linked_below(root: &Path, path: &Path) -> Result<()> {
let Ok(relative) = path.strip_prefix(root) else {
return Ok(());
};
let mut current = root.to_path_buf();
for component in relative.components() {
current.push(component);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => {
bail!(
"refusing to create a lane worktree through the link {}",
current.display()
);
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(error).with_context(|| format!("inspect {}", current.display()));
}
}
}
Ok(())
}
/// Create a git worktree + branch for a lane.
pub fn provision_worktree(spec: &WorktreeProvision) -> Result<ProvisionedWorktree> {
if spec.branch.trim().is_empty() {
@@ -41,6 +68,9 @@ pub fn provision_worktree(spec: &WorktreeProvision) -> Result<ProvisionedWorktre
bail!("worktree branch and base ref must not start with '-'");
}
if let Some(parent) = spec.path.parent() {
// The default location is `<repo>/.codewhale/lanes/<id>`: a link on
// the way down from the repository would send the checkout elsewhere.
reject_linked_below(&spec.repo_root, parent)?;
fs::create_dir_all(parent)
.with_context(|| format!("create worktree parent {}", parent.display()))?;
}
@@ -353,6 +383,28 @@ pub(crate) mod tests {
);
}
#[cfg(unix)]
#[test]
fn provision_refuses_a_linked_lanes_directory_under_the_repo() {
let dir = tempdir().unwrap();
let repo = dir.path().join("repo");
fs::create_dir_all(repo.join(".codewhale")).unwrap();
init_repo(&repo);
let outside = dir.path().join("outside");
fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, repo.join(".codewhale").join("lanes")).unwrap();
let error = provision_worktree(&WorktreeProvision {
repo_root: repo.clone(),
branch: "codex/lane-linked".into(),
path: repo.join(".codewhale").join("lanes").join("lane-1"),
base_ref: Some("main".into()),
})
.expect_err("a linked lanes directory must be refused");
assert!(format!("{error:#}").contains("link"), "{error:#}");
assert_eq!(fs::read_dir(&outside).unwrap().count(), 0);
}
#[test]
fn provision_refuses_option_shaped_base_ref() {
let dir = tempdir().unwrap();
+73 -20
View File
@@ -7,7 +7,7 @@
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{File, OpenOptions};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
@@ -233,7 +233,7 @@ impl LocalProcessFleetHostAdapter {
let env = worker_env(&request.env, &request.env_allowlist)?;
let log_path = self.log_path_for(&request.worker_id, host_kind);
let log = open_worker_log(&log_path)?;
let log = open_worker_log(&self.workspace, &log_path)?;
let stderr = log
.try_clone()
.map_err(|err| FleetHostError::retryable(format!("cloning worker log: {err}")))?;
@@ -425,7 +425,7 @@ impl FleetHostAdapter for LocalProcessFleetHostAdapter {
.get(worker_id)
.ok_or_else(|| FleetHostError::terminal(format!("unknown worker {worker_id}")))?;
let max_bytes = max_bytes.min(process.request.log_limit_bytes.max(1));
read_bounded_log(&process.log_path, max_bytes)
read_bounded_log(&self.workspace, &process.log_path, max_bytes)
}
fn interrupt_worker(&mut self, worker_id: &str) -> FleetHostResult<FleetHostWorkerStatus> {
@@ -808,25 +808,26 @@ fn restart_after_confirmed_stop<A: FleetHostAdapter>(
replace(adapter)
}
fn open_worker_log(path: &Path) -> FleetHostResult<File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
FleetHostError::retryable(format!(
"creating worker log dir {}: {err}",
parent.display()
))
})?;
}
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)
.map_err(|err| FleetHostError::retryable(format!("opening worker log: {err}")))
fn open_worker_log(workspace: &Path, path: &Path) -> FleetHostResult<File> {
// Pinned, no-follow open under the workspace: a link in `.codewhale` or at
// the log's name is refused instead of redirecting the worker's output.
let relative = path.strip_prefix(workspace).map_err(|_| {
FleetHostError::retryable(format!(
"worker log {} is outside the workspace",
path.display()
))
})?;
let file = super::files::WorkspaceFile::open(workspace, relative, true)
.and_then(|target| target.open_write(false))
.map_err(|err| FleetHostError::retryable(format!("opening worker log: {err}")))?;
// Validated handle first, truncation second.
file.set_len(0)
.map_err(|err| FleetHostError::retryable(format!("truncating worker log: {err}")))?;
Ok(file)
}
fn read_bounded_log(path: &Path, max_bytes: usize) -> FleetHostResult<String> {
let mut file = File::open(path).map_err(|err| {
fn read_bounded_log(workspace: &Path, path: &Path, max_bytes: usize) -> FleetHostResult<String> {
let mut file = crate::fs_confined::open_read(workspace, path).map_err(|err| {
FleetHostError::retryable(format!("opening worker log {}: {err}", path.display()))
})?;
let len = file
@@ -1608,6 +1609,58 @@ fn safe_path_segment(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
/// Worker output is opened through the pinned no-follow writer: a link in
/// `.codewhale` or at the log's name is refused, and a plain log is
/// truncated and readable back through the same confinement.
#[cfg(unix)]
#[test]
fn worker_logs_are_never_opened_through_links() {
use std::io::Write as _;
use std::os::unix::fs::symlink;
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let log = workspace
.path()
.join(".codewhale")
.join("fleet-host")
.join("local")
.join("w1.log");
let mut file = open_worker_log(workspace.path(), &log).expect("plain log opens");
file.write_all(b"first run output").unwrap();
drop(file);
assert_eq!(
read_bounded_log(workspace.path(), &log, 1024).unwrap(),
"first run output"
);
drop(open_worker_log(workspace.path(), &log).expect("reopen truncates"));
assert_eq!(read_bounded_log(workspace.path(), &log, 1024).unwrap(), "");
// A linked directory and a linked file are both refused.
let linked_root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(linked_root.path().join(".codewhale")).unwrap();
symlink(
outside.path(),
linked_root.path().join(".codewhale").join("fleet-host"),
)
.unwrap();
let linked_dir_log = linked_root
.path()
.join(".codewhale")
.join("fleet-host")
.join("local")
.join("w1.log");
assert!(open_worker_log(linked_root.path(), &linked_dir_log).is_err());
let secret = outside.path().join("secret.txt");
std::fs::write(&secret, "do not read").unwrap();
let parent = log.parent().unwrap();
symlink(&secret, parent.join("w2.log")).unwrap();
assert!(open_worker_log(workspace.path(), &parent.join("w2.log")).is_err());
assert!(read_bounded_log(workspace.path(), &parent.join("w2.log"), 1024).is_err());
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "do not read");
}
use tempfile::TempDir;
#[cfg(unix)]
+16 -21
View File
@@ -7,7 +7,6 @@
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -358,7 +357,11 @@ impl FleetManager {
fn manager_lock_path(&self, run_id: &FleetRunId) -> PathBuf {
self.workspace
.join(".codewhale")
.join(Self::manager_lock_relative_path(run_id))
}
fn manager_lock_relative_path(run_id: &FleetRunId) -> PathBuf {
PathBuf::from(".codewhale")
.join("fleet")
.join(format!("manager-{}.lock", safe_path_segment(&run_id.0)))
}
@@ -805,18 +808,13 @@ impl FleetManager {
// blocking pool (blocking-call convention, #6149).
let lock_file = {
let path = manager_lock_path.clone();
let workspace = self.workspace.clone();
let relative = Self::manager_lock_relative_path(run_id);
tokio::task::spawn_blocking(move || -> Result<std::fs::File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("creating Fleet manager lock dir {}", parent.display())
})?;
}
OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
// Created and opened through the pinned no-follow handle, so a
// linked `.codewhale` or lock name is refused, not followed.
super::files::WorkspaceFile::open(&workspace, &relative, true)
.and_then(|file| file.open_update(true, false))
.with_context(|| format!("opening Fleet manager lock {}", path.display()))
})
.await
@@ -2084,18 +2082,15 @@ impl FleetManager {
.join(safe_path_segment(&run_id.0))
.join(safe_path_segment(&task_spec.id))
.join(format!("{}.log", safe_path_segment(worker_id)));
let abs_path = self.workspace.join(&rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating Fleet artifact dir {}", parent.display()))?;
}
let contents = format!(
"run_id={}\ntask_id={}\ntask_name={}\nworker_id={}\nstatus=started\n",
run_id.0, task_spec.id, task_spec.name, worker_id
);
std::fs::write(&abs_path, contents)
.with_context(|| format!("writing Fleet worker log {}", abs_path.display()))?;
let size_bytes = std::fs::metadata(&abs_path).ok().map(|m| m.len());
// The same pinned, no-follow writer as every other Fleet artifact: a
// link in `.codewhale` cannot send the log outside the workspace.
super::artifacts::write(&self.workspace, &rel_path, contents.as_bytes())
.with_context(|| format!("writing Fleet worker log {}", rel_path.display()))?;
let size_bytes = Some(contents.len() as u64);
Ok(FleetArtifactRef {
kind: FleetArtifactKind::Log,
path: rel_path,
+96 -2
View File
@@ -612,6 +612,7 @@ fn ensure_fleets_dir(scope: FleetScope, workspace: &Path) -> Result<PathBuf, Fle
FleetScope::Personal => personal_fleets_dir()?,
FleetScope::Workspace => workspace_fleets_dir(workspace),
};
reject_linked_workspace_dir(scope, workspace, &dir)?;
fs::create_dir_all(&dir).map_err(|e| FleetStoreError::Io {
path: dir.display().to_string(),
message: e.to_string(),
@@ -619,6 +620,23 @@ fn ensure_fleets_dir(scope: FleetScope, workspace: &Path) -> Result<PathBuf, Fle
Ok(dir)
}
/// A workspace's `.codewhale/fleets` is repository content: refuse it when it,
/// or `.codewhale`, is a link, so saving, selecting or deleting a Fleet cannot
/// reach outside the workspace. The personal directory is the user's own.
fn reject_linked_workspace_dir(
scope: FleetScope,
workspace: &Path,
dir: &Path,
) -> Result<(), FleetStoreError> {
if scope != FleetScope::Workspace {
return Ok(());
}
super::files::reject_linked_path(workspace, dir).map_err(|e| FleetStoreError::Io {
path: dir.display().to_string(),
message: e.to_string(),
})
}
/// List every named Fleet across both scopes, personal first. A file that is
/// not a v2 Fleet is listed as `legacy` with its parse error, so an old exact
/// fleet is visible — never silently absent — while the user decides whether
@@ -861,7 +879,7 @@ pub fn save_fleet(
}
}
let rendered = fleet.render_toml()?;
atomic_write(&path, rendered.as_bytes())?;
write_scoped(scope, workspace, &path, rendered.as_bytes())?;
Ok(path)
}
@@ -875,6 +893,7 @@ pub fn delete_fleet(
FleetScope::Personal => personal_fleets_dir()?,
FleetScope::Workspace => workspace_fleets_dir(workspace),
};
reject_linked_workspace_dir(scope, workspace, &dir)?;
let path = dir.join(format!("{}.toml", slugify(name)));
if !path.is_file() {
return Err(FleetStoreError::NotFound(name.to_string()));
@@ -1006,7 +1025,7 @@ pub fn set_selected(
)));
}
let selected = dir.join(SELECTED_FILE);
atomic_write(&selected, name.as_bytes())?;
write_scoped(scope, workspace, &selected, name.as_bytes())?;
Ok(selected)
}
@@ -1016,12 +1035,43 @@ fn clear_selection_if_matching(scope: FleetScope, workspace: &Path, name: &str)
FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
};
let Some(dir) = dir else { return };
if reject_linked_workspace_dir(scope, workspace, &dir).is_err() {
return;
}
let selected = dir.join(SELECTED_FILE);
if read_selection(&dir).as_deref() == Some(name.trim()) {
let _ = fs::remove_file(selected);
}
}
/// Write a Fleet or selection file for `scope`. A workspace file goes through
/// the pinned no-follow writer (parents created without following a link, the
/// final name replaced, never written through); the personal scope is the
/// user's own directory and keeps the plain atomic write.
fn write_scoped(
scope: FleetScope,
workspace: &Path,
path: &Path,
bytes: &[u8],
) -> Result<(), FleetStoreError> {
if scope != FleetScope::Workspace {
return atomic_write(path, bytes);
}
let io_error = |e: std::io::Error| FleetStoreError::Io {
path: path.display().to_string(),
message: e.to_string(),
};
let relative = path
.strip_prefix(workspace)
.map_err(|_| FleetStoreError::Io {
path: path.display().to_string(),
message: "a workspace Fleet file must stay within the workspace".to_string(),
})?;
super::files::WorkspaceFile::open_shared(workspace, relative, true)
.and_then(|file| file.replace(bytes))
.map_err(io_error)
}
/// Atomic write: temp file in the same directory, then rename. A failed write
/// never leaves a half-written Fleet or selection.
/// Each write gets its own temp file, so two concurrent savers can never
@@ -1177,6 +1227,50 @@ mod tests {
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", sealed_home())
}
/// A workspace's `.codewhale/fleets` is repository content. Saving,
/// selecting and deleting refuse it when it is a link, and a link at the
/// Fleet's own file name is replaced rather than written through.
#[cfg(unix)]
#[test]
fn workspace_fleet_writes_never_follow_links_out_of_the_workspace() {
use std::os::unix::fs::symlink;
let _lock = crate::test_support::lock_test_env();
let _home = set_sealed_home();
let outside = tempfile::tempdir().unwrap();
// `.codewhale/fleets` linked: save, select and delete are all refused.
let linked = tempfile::tempdir().unwrap();
std::fs::create_dir_all(linked.path().join(".codewhale")).unwrap();
symlink(
outside.path(),
linked.path().join(".codewhale").join(FLEET_DIR),
)
.unwrap();
let fleet = sample_fleet();
assert!(save_fleet(&fleet, FleetScope::Workspace, linked.path()).is_err());
assert!(set_selected("DeepSeek Flash", FleetScope::Workspace, linked.path()).is_err());
assert!(delete_fleet("DeepSeek Flash", FleetScope::Workspace, linked.path()).is_err());
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
// A link at the Fleet's file name is replaced, never written through.
let plain = tempfile::tempdir().unwrap();
let dir = plain.path().join(".codewhale").join(FLEET_DIR);
std::fs::create_dir_all(&dir).unwrap();
let victim = outside.path().join("victim.toml");
std::fs::write(&victim, "keep me").unwrap();
symlink(&victim, dir.join(format!("{}.toml", fleet.file_slug()))).unwrap();
let saved = save_fleet(&fleet, FleetScope::Workspace, plain.path());
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep me");
if let Ok(path) = saved {
assert!(
!std::fs::symlink_metadata(&path)
.unwrap()
.file_type()
.is_symlink()
);
}
}
#[test]
fn declared_schema_separates_unreadable_from_undeclared() {
let dir = tempfile::tempdir().unwrap();
+22
View File
@@ -82,6 +82,26 @@ fn validate_mcp_config_path(path: &Path) -> Result<()> {
Ok(())
}
/// Refuse to write a workspace's `.codewhale/mcp.json` through a link: when
/// the file lives under a `<workspace>/.codewhale`, no component from the
/// workspace down may be a link. The user's own home config (`~/.codewhale`) is
/// exempt, because users relocate it on purpose. Writers only: reading a linked
/// file is still allowed, as before.
fn reject_linked_workspace_state_path(path: &Path) -> Result<()> {
let Some(root) = path
.ancestors()
.find(|ancestor| ancestor.file_name() == Some(std::ffi::OsStr::new(".codewhale")))
.and_then(Path::parent)
else {
return Ok(());
};
if crate::config::effective_home_dir().is_some_and(|home| home == root) {
return Ok(());
}
crate::fleet::files::reject_linked_path(root, path)
.with_context(|| format!("MCP config {} is not safe to write", path.display()))
}
/// Expand `${NAME}` placeholders in an MCP config value from the process
/// environment. This lets secrets (API keys, bearer tokens, …) be supplied
/// through environment variables instead of being written in cleartext into
@@ -6745,6 +6765,7 @@ pub fn mutate_config<T>(
mutate: impl FnOnce(&mut McpConfig) -> Result<T>,
) -> Result<(T, String)> {
validate_mcp_config_path(path)?;
reject_linked_workspace_state_path(path)?;
codewhale_config::with_config_write_lock(path, |path| {
let original = read_mcp_config_file(path)?;
let revision = config_revision(original.as_deref());
@@ -6828,6 +6849,7 @@ fn mcp_template_json() -> Result<String> {
pub fn init_config(path: &Path, force: bool) -> Result<McpWriteStatus> {
validate_mcp_config_path(path)?;
reject_linked_workspace_state_path(path)?;
codewhale_config::with_config_write_lock(path, |path| {
let original = read_mcp_config_file(path)?;
if let Some(raw) = original.as_deref() {
+43
View File
@@ -2733,6 +2733,49 @@ fn init_mcp_config_rejects_traversal_before_parent_creation() {
);
}
/// A workspace's `.codewhale/mcp.json` is never written through a link: not
/// when `.codewhale` itself is a link, and not when the file is. Reads stay
/// unchanged and the outside directory is never touched.
#[cfg(unix)]
#[test]
fn project_mcp_config_writes_refuse_links_out_of_the_workspace() {
use std::os::unix::fs::symlink;
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), workspace.path().join(".codewhale")).unwrap();
let path = workspace_mcp_config_path(workspace.path());
let err = init_config(&path, false).expect_err("a linked .codewhale is refused");
assert!(
format!("{err:#}").contains("Refusing symlinked"),
"got: {err:#}"
);
let err = mutate_config(&path, None, |_| Ok(())).expect_err("mutation is refused too");
assert!(
format!("{err:#}").contains("Refusing symlinked"),
"got: {err:#}"
);
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
// A linked file inside a real `.codewhale` is refused as well.
let other = tempfile::tempdir().unwrap();
std::fs::create_dir_all(other.path().join(".codewhale")).unwrap();
let target = outside.path().join("target.json");
symlink(&target, other.path().join(".codewhale").join("mcp.json")).unwrap();
assert!(init_config(&workspace_mcp_config_path(other.path()), false).is_err());
assert!(
!target.exists(),
"a link at the file name must not be created through"
);
// An ordinary workspace still works.
let plain = tempfile::tempdir().unwrap();
assert_eq!(
init_config(&workspace_mcp_config_path(plain.path()), false).unwrap(),
McpWriteStatus::Created
);
}
#[test]
fn test_mcp_config_manager_actions_round_trip() {
let dir = tempfile::tempdir().unwrap();
+33
View File
@@ -6387,6 +6387,39 @@ mod tests {
assert!(body.contains("\"confirmed\": 2"), "{body}");
}
/// A workspace that ships `.codewhale/reports` as a link cannot make the
/// run report or the raw schema reply land outside the workspace.
#[cfg(unix)]
#[test]
fn report_artifacts_are_never_written_through_a_linked_reports_directory() {
let tmp = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("outside");
std::fs::create_dir_all(tmp.path().join(".codewhale")).expect("mkdir");
std::os::unix::fs::symlink(
outside.path(),
tmp.path().join(".codewhale").join("reports"),
)
.expect("link");
let mut record = WorkflowRunRecord::new(
"workflow_report_linked".to_string(),
Some("session-test".to_string()),
None,
None,
None,
);
record.status = WorkflowRunStatus::Completed;
write_run_report_artifact(tmp.path(), &record);
let schema = write_schema_raw_artifact(tmp.path(), "run-linked", "agent_0001", 1, "raw");
assert!(schema.is_none(), "the schema artifact must be refused");
assert_eq!(
std::fs::read_dir(outside.path()).expect("outside").count(),
0,
"nothing may land behind the link"
);
}
#[test]
fn running_runs_write_no_report_artifact() {
let tmp = tempfile::tempdir().expect("tempdir");
+10 -21
View File
@@ -18,17 +18,12 @@ pub(super) fn write_run_report_artifact(workspace: &Path, record: &WorkflowRunRe
return;
};
let path = workspace.join(&relative);
let Some(dir) = path.parent() else {
return;
};
if let Err(err) = std::fs::create_dir_all(dir) {
crate::logging::warn(format!(
"workflow report dir {} not created: {err}",
dir.display()
));
return;
}
if let Err(err) = std::fs::write(&path, render_run_report(record)) {
// Written through the confined helper: a link in `.codewhale` (or at the
// report's own name) is refused instead of redirecting the write out of
// the workspace, and missing parents are created without following links.
if let Err(err) =
crate::fs_confined::write(workspace, &path, render_run_report(record).as_bytes())
{
crate::logging::warn(format!(
"workflow report {} not written: {err}",
path.display()
@@ -97,18 +92,12 @@ pub(super) fn write_schema_raw_artifact(
if safe_run.is_empty() || safe_task.is_empty() {
return None;
}
let dir = workspace.join(".codewhale").join("reports");
if let Err(err) = std::fs::create_dir_all(&dir) {
crate::logging::warn(format!(
"workflow schema artifact dir {} not created: {err}",
dir.display()
));
return None;
}
let path = dir.join(format!(
let path = workspace.join(".codewhale").join("reports").join(format!(
"{safe_run}.schema.{safe_task}.attempt{attempt}.txt"
));
match std::fs::write(&path, raw) {
// Confined write: no link below the workspace is followed, and the missing
// `.codewhale/reports` parents are created without following one.
match crate::fs_confined::write(workspace, &path, raw.as_bytes()) {
Ok(()) => Some(path.display().to_string()),
Err(err) => {
crate::logging::warn(format!(
+16 -4
View File
@@ -2807,11 +2807,23 @@ fn apply_validated_profile_config(
/// time, and reloads the hook set on return so the screen reflects the edit
/// immediately.
fn edit_project_hooks_from_tui(terminal: &mut AppTerminal, app: &mut App, config: &Config) {
let dir = app.workspace.join(".codewhale");
let path = dir.join("hooks.toml");
let path = app.workspace.join(".codewhale").join("hooks.toml");
// A link in `.codewhale` or at hooks.toml is never created through, and the
// editor is not pointed at one: the file must really live in the workspace.
if let Err(error) = crate::fleet::files::reject_linked_path(&app.workspace, &path) {
app.push_status_toast(
format!("Could not use {}: {error}", path.display()),
StatusToastLevel::Warning,
Some(8_000),
);
return;
}
if !path.exists()
&& let Err(error) = std::fs::create_dir_all(&dir)
.and_then(|()| std::fs::write(&path, crate::hooks::PROJECT_HOOKS_TEMPLATE))
&& let Err(error) = crate::fs_confined::write(
&app.workspace,
&path,
crate::hooks::PROJECT_HOOKS_TEMPLATE.as_bytes(),
)
{
app.push_status_toast(
format!("Could not create {}: {error}", path.display()),