From 96fa78e86ebfcd26020991cb6ba468bfe645a4f3 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 1 Nov 2026 15:33:26 +0700 Subject: [PATCH] =?UTF-8?q?A=20builder=20that=20writes=20/app/=E2=80=A6=20?= =?UTF-8?q?paths=20builds=20the=20app=20instead=20of=20being=20refused=20(?= =?UTF-8?q?#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builders driven by our own loop (an API model such as Ollama) get file tools whose paths are relative to the project. Models often write the project's folders from the root (/app/src/…), which was refused as outside the project, so they could neither list, read nor write app/ and went round in circles while each check found nothing. A path from the root whose first folder the project has is now read inside the project (/etc/… and folders it doesn't have are still refused), and a write outside app/ says so in its result, so a model that leaves out app/ learns where the app goes. Refs #24 --- crates/engine/src/tools/build_tools.rs | 41 +++++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tools/build_tools.rs b/crates/engine/src/tools/build_tools.rs index ac38ae5b..7da6abaf 100644 --- a/crates/engine/src/tools/build_tools.rs +++ b/crates/engine/src/tools/build_tools.rs @@ -194,14 +194,15 @@ fn workspace(ctx: &Ctx<'_>) -> Result { } /// `rel` inside the project folder, or an error (no escaping with `..` or an absolute path). +/// Models often write the project's folders from the root (`/app/src/main.rs`): a path like that +/// whose first folder the project has is read inside the project, where it was meant (#24). fn in_project(ws: &Workspace, rel: &str) -> Result { - match resolve(&ws.root, rel) { - Some(p) if inside(&ws.root, &p) => Ok(p), - _ => { - Err(ToolError::invalid(format!("{rel} is outside the project.")) - .with_remediation("Use a path relative to the project folder, like app/src/main.rs.")) - } - } + let from_root = rel.trim_start_matches(['/', '\\']); + let names_a_folder = from_root != rel + && from_root.split(['/', '\\']).next().is_some_and(|first| !first.is_empty() && (ws.root.join(first).is_dir() || ws.root.join(first) == ws.app_dir())); + [Some(rel), names_a_folder.then_some(from_root)].into_iter().flatten().find_map(|r| resolve(&ws.root, r).filter(|p| inside(&ws.root, p))).ok_or_else(|| { + ToolError::invalid(format!("{rel} is outside the project.")).with_remediation("Use a path relative to the project folder, like app/src/main.rs.") + }) } fn shown(ws: &Workspace, p: &Path) -> String { @@ -284,7 +285,12 @@ fn build_file_write(ctx: &mut Ctx<'_>, params: Value) -> Result, params: Value) -> Result { @@ -379,6 +385,25 @@ mod tests { assert!(call(&e, "build_file_write", json!({"path": "../outside.txt", "content": "x"})).is_err()); } + /// #24: a model that writes `/app/…` builds the app, and one that leaves out `app/` is told. + #[test] + fn paths_from_the_root_name_the_project_folders() { + let (_d, ws, e) = building(); + let r = call(&e, "build_file_write", json!({"path": "/app/package.json", "content": "{}"})).unwrap(); + assert_eq!((r["written"].as_str(), r.get("note")), (Some("app/package.json"), None)); + assert!(ws.app_dir().join("package.json").is_file()); + // Backslashes separate folders only on Windows; elsewhere they're part of a name. + let read = if cfg!(windows) { "\\app\\package.json" } else { "/app/package.json" }; + assert_eq!(call(&e, "build_file_read", json!({"path": read})).unwrap()["text"], "{}\n"); + assert!(call(&e, "build_files_list", json!({"path": "/app"})).unwrap()["entries"].to_string().contains("app/package.json")); + // A folder the project doesn't have stays outside it. + assert!(call(&e, "build_file_read", json!({"path": "/etc/passwd"})).is_err()); + assert!(call(&e, "build_file_write", json!({"path": "/nowhere/x.txt", "content": "x"})).is_err()); + assert!(call(&e, "build_file_write", json!({"path": "/../outside.txt", "content": "x"})).is_err()); + let stray = call(&e, "build_file_write", json!({"path": "index.html", "content": "

hi

"})).unwrap(); + assert!(stray["note"].as_str().unwrap().contains("outside app/"), "{stray}"); + } + #[test] fn writes_outside_app_and_dev_servers_follow_the_policy() { let (_d, ws, e) = building();