fix(notes): keep the auto-approved note tool inside the workspace

A repo-level `.codewhale/config.toml` (or legacy `.deepseek/`) could set
`notes_path = "~/.zshrc"`, and the auto-approved `note` tool would then
append model text to the user's shell rc file.

- Project-scope `notes_path` is honoured only as a relative path inside
  the workspace (resolved against it); `~`, `$`, absolute, rooted and
  `..` paths are ignored with a warning. User-config `notes_path` is
  unchanged.
- The note tool refuses a notes target that is a symlink or not a
  regular file, and one placed in the workspace whose existing directory
  resolves outside it after following symlinks (a committed
  `notes.md -> ~/.zshrc` or `notes/ -> ~`).

Tests: codewhale-tui project_config_tests 22 passed / 0 failed (new
project_overlay_keeps_notes_path_inside_the_workspace);
tools::shell::tests::note_tool_refuses_symlinked_targets_that_leave_the_workspace
1 passed / 0 failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 05:37:43 -07:00
1 parent ecdc8405b1
commit d0e31586a5
3 files changed
+162 -8

No files matched your search

+68 -8
View File
@@ -11025,6 +11025,24 @@ fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path)
}
}
/// Resolve a project-scope `notes_path` against the workspace, or `None` when
/// it could leave it: absolute, rooted, `~`-prefixed, or containing `..`.
fn project_notes_path_inside_workspace(workspace: &Path, value: &str) -> Option<PathBuf> {
use std::path::Component;
let value = value.trim();
if value.is_empty() || value.starts_with('~') || value.contains('$') {
return None;
}
let relative = Path::new(value);
let contained = relative
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
&& relative
.components()
.any(|component| matches!(component, Component::Normal(_)));
contained.then(|| workspace.join(relative))
}
/// Load project-level config from `$WORKSPACE/.codewhale/config.toml`, with
/// legacy `$WORKSPACE/.deepseek/config.toml` fallback, then apply its fields as
/// overrides on top of the global config (#485).
@@ -11140,14 +11158,25 @@ fn merge_project_config_with_approval_baseline(
config.set_provider_model_override(config.api_provider(), Some(model.to_string()));
config.remembered_selection_scope = Some(false);
}
for (key, field) in [
("reasoning_effort", &mut config.reasoning_effort),
("notes_path", &mut config.notes_path),
] {
if let Some(v) = table.get(key).and_then(toml::Value::as_str)
&& !v.is_empty()
{
*field = Some(v.to_string());
if let Some(v) = table.get("reasoning_effort").and_then(toml::Value::as_str)
&& !v.is_empty()
{
config.reasoning_effort = Some(v.to_string());
}
// The `note` tool is auto-approved and appends to `notes_path`, so a
// project value is a write target the user never reviewed. Only a
// relative path that stays inside the workspace is honoured; `~`,
// absolute and `..` paths (e.g. `~/.zshrc`) are ignored.
if let Some(v) = table.get("notes_path").and_then(toml::Value::as_str)
&& !v.is_empty()
{
match project_notes_path_inside_workspace(workspace, v) {
Some(path) => config.notes_path = Some(path.to_string_lossy().into_owned()),
None => eprintln!(
"warning: project-scope `notes_path = \"{v}\"` is ignored — \
a project may only point notes at a relative path inside the workspace. \
Set it in `~/.codewhale/config.toml` to write elsewhere."
),
}
}
@@ -19383,6 +19412,37 @@ sandbox_mode = "read-only"
assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
}
#[test]
fn project_overlay_keeps_notes_path_inside_the_workspace() {
// The auto-approved `note` tool appends to `notes_path`; a project
// must not aim it at `~/.zshrc` or any file outside the workspace.
for escape in [
"~/.zshrc",
"/etc/profile",
"../../.bashrc",
"notes/../../outside.md",
"$HOME/.zshrc",
"..",
] {
let tmp = workspace_with_project_config(&format!("notes_path = {escape:?}\n"));
let mut config = Config::default();
merge_project_config(&mut config, tmp.path());
assert_eq!(
config.notes_path, None,
"project notes_path {escape:?} must be ignored"
);
}
let tmp = workspace_with_project_config("notes_path = \"docs/agent-notes.md\"\n");
let mut config = Config::default();
merge_project_config(&mut config, tmp.path());
assert_eq!(
config.notes_path(),
tmp.path().join("docs/agent-notes.md"),
"a relative project notes_path resolves inside the workspace"
);
}
#[test]
fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
// #417 value-deny: the loosest values (`approval_policy = "auto"`,
+44
View File
@@ -6598,6 +6598,49 @@ fn shell_delta_with_accumulated_output(
}
}
/// Refuse a notes target this auto-approved tool must not append to.
///
/// The file itself must not be a symlink (a committed `notes.md -> ~/.zshrc`
/// would redirect the append), and a target placed inside the workspace must
/// resolve inside it after symlinked parent directories are followed.
async fn ensure_notes_target_is_safe(
notes_path: &std::path::Path,
workspace: &std::path::Path,
) -> Result<(), ToolError> {
if let Ok(meta) = tokio::fs::symlink_metadata(notes_path).await
&& (meta.file_type().is_symlink() || !meta.is_file())
{
return Err(ToolError::permission_denied(format!(
"Refusing to append a note to {}: the notes path is a symlink or not a regular file.",
notes_path.display()
)));
}
if notes_path.starts_with(workspace)
&& let (Some(parent), Ok(root)) = (
notes_path.parent(),
tokio::fs::canonicalize(workspace).await,
)
{
// Walk up to the nearest existing ancestor: the rest is created
// below as real directories, so only existing links can redirect.
let mut existing = parent.to_path_buf();
while !tokio::fs::try_exists(&existing).await.unwrap_or(false) {
if !existing.pop() {
break;
}
}
if let Ok(resolved) = tokio::fs::canonicalize(&existing).await
&& !resolved.starts_with(&root)
{
return Err(ToolError::permission_denied(format!(
"Refusing to append a note to {}: its directory resolves outside the workspace.",
notes_path.display()
)));
}
}
Ok(())
}
/// Tool for appending notes to a notes file.
pub struct NoteTool;
@@ -6638,6 +6681,7 @@ impl ToolSpec for NoteTool {
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let note_content = required_str(&input, "content")?;
ensure_notes_target_is_safe(&context.notes_path, &context.workspace).await?;
// Ensure parent directory exists. Tool handlers run on the Tokio
// runtime, so filesystem calls use tokio::fs (blocking-call
+50
View File
@@ -4952,3 +4952,53 @@ fn pty_raw_stdin_roundtrips_nul_and_non_utf8_bytes() {
std::thread::sleep(Duration::from_millis(20));
}
}
/// The auto-approved `note` tool appends to the configured notes file. A
/// committed symlink (`notes.md -> ~/.zshrc`) or a symlinked notes directory
/// must not redirect that append outside the workspace.
#[cfg(unix)]
#[tokio::test]
async fn note_tool_refuses_symlinked_targets_that_leave_the_workspace() {
let workspace = tempdir().expect("workspace");
let outside = tempdir().expect("outside");
let rc = outside.path().join(".zshrc");
std::fs::write(&rc, "# rc\n").expect("write rc");
let linked_file = workspace.path().join("notes.md");
std::os::unix::fs::symlink(&rc, &linked_file).expect("symlink notes file");
let context = ToolContext::with_options(workspace.path(), false, &linked_file, "mcp.json");
let err = NoteTool
.execute(json!({"content": "echo pwned"}), &context)
.await
.expect_err("a symlinked notes file must be refused");
assert!(err.to_string().contains("symlink"), "{err}");
let linked_dir = workspace.path().join("notes");
std::os::unix::fs::symlink(outside.path(), &linked_dir).expect("symlink notes dir");
let context = ToolContext::with_options(
workspace.path(),
false,
linked_dir.join("sub/notes.md"),
"mcp.json",
);
let err = NoteTool
.execute(json!({"content": "echo pwned"}), &context)
.await
.expect_err("a notes dir resolving outside the workspace must be refused");
assert!(err.to_string().contains("outside the workspace"), "{err}");
assert_eq!(std::fs::read_to_string(&rc).expect("read rc"), "# rc\n");
assert!(!outside.path().join("sub").exists());
let plain = workspace.path().join("docs/notes.md");
let context = ToolContext::with_options(workspace.path(), false, &plain, "mcp.json");
NoteTool
.execute(json!({"content": "kept"}), &context)
.await
.expect("an ordinary in-workspace notes file is writable");
assert!(
std::fs::read_to_string(&plain)
.expect("read notes")
.contains("kept")
);
}