mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Reject workspace paths in write_file, force LLM to use memory_write
write_file now detects workspace files (HEARTBEAT.md, MEMORY.md, SOUL.md, etc.) and daily/context/ prefixes, returning an error that tells the LLM to use memory_write with the correct target instead. This prevents the LLM from writing workspace data to the local filesystem when it should go to the database. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7fcc2279cc
commit
4f8fd4ad5f
@@ -12,6 +12,34 @@ use tokio::fs;
|
|||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||||
|
use crate::workspace::paths as ws_paths;
|
||||||
|
|
||||||
|
/// Well-known workspace filenames that must go through memory_write, not write_file.
|
||||||
|
///
|
||||||
|
/// If the LLM tries to write one of these via the filesystem tool we reject
|
||||||
|
/// immediately and point it at the correct tool.
|
||||||
|
const WORKSPACE_FILES: &[&str] = &[
|
||||||
|
ws_paths::HEARTBEAT,
|
||||||
|
ws_paths::MEMORY,
|
||||||
|
ws_paths::IDENTITY,
|
||||||
|
ws_paths::SOUL,
|
||||||
|
ws_paths::AGENTS,
|
||||||
|
ws_paths::USER,
|
||||||
|
ws_paths::README,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Check whether `path` resolves to a workspace file that should be written
|
||||||
|
/// through `memory_write` instead of `write_file`.
|
||||||
|
fn is_workspace_path(path: &str) -> bool {
|
||||||
|
let filename = std::path::Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or(path);
|
||||||
|
|
||||||
|
WORKSPACE_FILES.iter().any(|ws| *ws == filename)
|
||||||
|
|| path.starts_with("daily/")
|
||||||
|
|| path.starts_with("context/")
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum file size for reading (1MB).
|
/// Maximum file size for reading (1MB).
|
||||||
const MAX_READ_SIZE: u64 = 1024 * 1024;
|
const MAX_READ_SIZE: u64 = 1024 * 1024;
|
||||||
@@ -276,6 +304,15 @@ impl Tool for WriteFileTool {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
||||||
|
|
||||||
|
// Reject workspace paths: these live in the database, not on disk.
|
||||||
|
if is_workspace_path(path_str) {
|
||||||
|
return Err(ToolError::InvalidParameters(format!(
|
||||||
|
"'{}' is a workspace memory file. Use the memory_write tool instead of write_file. \
|
||||||
|
For HEARTBEAT.md use target='heartbeat', for MEMORY.md use target='memory'.",
|
||||||
|
path_str
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
let content = params
|
let content = params
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -726,6 +763,78 @@ mod tests {
|
|||||||
assert!(content.contains("println!(\"new\")"));
|
assert!(content.contains("println!(\"new\")"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_write_file_rejects_workspace_paths() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let tool = WriteFileTool::new().with_base_dir(dir.path().to_path_buf());
|
||||||
|
let ctx = JobContext::default();
|
||||||
|
|
||||||
|
let workspace_files = &[
|
||||||
|
"HEARTBEAT.md",
|
||||||
|
"MEMORY.md",
|
||||||
|
"IDENTITY.md",
|
||||||
|
"SOUL.md",
|
||||||
|
"AGENTS.md",
|
||||||
|
"USER.md",
|
||||||
|
"README.md",
|
||||||
|
];
|
||||||
|
|
||||||
|
for filename in workspace_files {
|
||||||
|
let path = dir.path().join(filename);
|
||||||
|
let err = tool
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({
|
||||||
|
"path": path.to_str().unwrap(),
|
||||||
|
"content": "test"
|
||||||
|
}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("memory_write"),
|
||||||
|
"Rejection for {} should mention memory_write, got: {}",
|
||||||
|
filename,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// daily/ and context/ prefixes should also be rejected
|
||||||
|
for prefix_path in &["daily/2024-01-15.md", "context/vision.md"] {
|
||||||
|
let err = tool
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({
|
||||||
|
"path": prefix_path,
|
||||||
|
"content": "test"
|
||||||
|
}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("memory_write"),
|
||||||
|
"Rejection for {} should mention memory_write",
|
||||||
|
prefix_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular files should still work
|
||||||
|
let regular_path = dir.path().join("normal.txt");
|
||||||
|
let result = tool
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({
|
||||||
|
"path": regular_path.to_str().unwrap(),
|
||||||
|
"content": "fine"
|
||||||
|
}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_list_dir() {
|
async fn test_list_dir() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user