mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
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:
1 parent
ecdc8405b1
commit
d0e31586a5
3 files changed
+162
-8
No files matched your search
+68
-8
@@ -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"`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user