fix: complete CLI --contain for all write paths by SebTardif · Pull Request #1410 · patchloom/patchloom · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/getting-started/concepts.md
2 changes: 1 addition & 1 deletion docs/reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ These flags affect how Patchloom reports results or chooses which files to touch
<!-- ref:global-flag:contain -->
### `--contain`

- **What it does:** Rejects file paths that escape the working directory (via `../` or symlinks that resolve outside the workspace). Mirrors MCP / library `PathGuard` behavior.
- **What it does:** Rejects file paths that escape the working directory (via `../`, absolute paths outside the policy, or symlinks that resolve outside the workspace). Mirrors MCP / library `PathGuard` behavior for **all CLI write paths**, including engine-backed commands (`create`, `delete`, `replace`, `patch`, `md`, `doc`, `tidy`, text `rename`, `tx` / `batch`) and callback-only mutations (binary and case-only `rename`).
- **Use when:** An agent or automation should not be able to write outside `--cwd` (or the process cwd). Pair with `--cwd` for a workspace root.
- **Default:** Off. CLI remains unrestricted for human scripts (same trust model as `make` / `sh`).
- **Prefer instead:** Use the MCP server when the agent already has MCP tools; containment is always on there.
Expand Down
2 changes: 1 addition & 1 deletion src/ast/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! [`rewrite_function_signature`] with [`FunctionSigEdit`] for structured
//! multi-language edits (visibility, parameters, return type).
//!
//! size-waiver: domain bulk, see #1376. Multi-language function signature rewrite domain; tests co-located in module.
//! size-waiver: domain bulk, see #1408. Multi-language function signature rewrite domain; tests co-located in module.

use super::symbol_extract::innermost_qualified_name;
use super::{Language, child_text_by_kind, child_text_by_kinds, parse_source};
Expand Down
2 changes: 1 addition & 1 deletion src/cmd/mcp/tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// size-waiver: domain bulk, see #1376. MCP integration unit tests co-located with server module.
// size-waiver: co-located MCP unit tests, see #1408. Intentionally co-located with the server module for shared test helpers; not unfinished Phase 4 work.
use super::*;
use rmcp::ServiceExt;

Expand Down
157 changes: 157 additions & 0 deletions src/cmd/rename.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ pub fn run(args: RenameArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
args.force
);
let cwd = global.resolve_cwd()?;
// Enforce --contain for all rename paths (engine-backed text and
// binary/case-only callback via write_dispatch). Engine-backed ops also
// check declared_paths in the tx engine; this early check covers the
// callback path that never reaches the engine (#1409).
if let Some(guard) = global.workspace_guard(&cwd)? {
for p in [&args.from, &args.to] {
guard
.check_path(p)
.map_err(|e| anyhow::anyhow!("path rejected by workspace guard: {e}"))?;
}
}
let src = cwd.join(&args.from);
let dst = cwd.join(&args.to);

Expand Down Expand Up @@ -451,6 +462,152 @@ mod tests {
assert_eq!(fs::read(&dst).unwrap(), b"\x00\x01\x02\xff\xfe");
}

#[test]
fn rename_with_contain_rejects_parent_escape_on_to() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("inside.txt"), "keep\n").unwrap();

let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
global.contain = true;

let args = RenameArgs {
from: "inside.txt".into(),
to: "../escape-renamed.txt".into(),
force: false,
write: Default::default(),
};

let err = run(args, &global).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("escapes") || msg.contains("rejected"),
"expected containment error, got: {msg}"
);
assert!(
dir.path().join("inside.txt").exists(),
"source must remain when containment rejects destination"
);
assert!(!dir.path().join("../escape-renamed.txt").exists());
}

#[test]
fn rename_with_contain_rejects_parent_escape_on_from() {
let dir = TempDir::new().unwrap();
let outside = dir.path().parent().unwrap().join(format!(
"patchloom-rename-from-escape-{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
));
fs::write(&outside, "outside\n").unwrap();

let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
global.contain = true;

let args = RenameArgs {
from: format!("../{}", outside.file_name().unwrap().to_string_lossy()),
to: "pulled-in.txt".into(),
force: false,
write: Default::default(),
};

let err = run(args, &global).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("escapes") || msg.contains("rejected"),
"expected containment error, got: {msg}"
);
assert!(outside.exists(), "outside source must not be moved");
assert!(!dir.path().join("pulled-in.txt").exists());
let _ = fs::remove_file(&outside);
}

#[test]
fn rename_binary_with_contain_rejects_parent_escape() {
// Binary rename uses write_dispatch (not the tx engine). Containment
// must still apply (#1409).
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("image.bin"), b"\x00\x01\x02\xff\xfe").unwrap();

let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
global.contain = true;

let args = RenameArgs {
from: "image.bin".into(),
to: "../escaped.bin".into(),
force: false,
write: Default::default(),
};

let err = run(args, &global).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("escapes") || msg.contains("rejected"),
"expected containment error on binary rename escape, got: {msg}"
);
assert!(dir.path().join("image.bin").exists());
assert!(!dir.path().join("../escaped.bin").exists());
}

#[test]
fn rename_with_contain_allows_in_workspace_relative_path() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("old.txt"), "hello\n").unwrap();

let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
global.contain = true;

let args = RenameArgs {
from: "old.txt".into(),
to: "new.txt".into(),
force: false,
write: Default::default(),
};

let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!dir.path().join("old.txt").exists());
assert_eq!(
fs::read_to_string(dir.path().join("new.txt")).unwrap(),
"hello\n"
);
}

#[test]
fn rename_without_contain_allows_parent_escape() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("inside.txt"), "move me\n").unwrap();
let name = format!(
"patchloom-rename-escape-{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
);

let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
// contain defaults to false

let args = RenameArgs {
from: "inside.txt".into(),
to: format!("../{name}"),
force: false,
write: Default::default(),
};

let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let outside = dir.path().parent().unwrap().join(&name);
assert_eq!(fs::read_to_string(&outside).unwrap(), "move me\n");
let _ = fs::remove_file(&outside);
}

#[test]
fn rename_creates_parent_dirs() {
let dir = TempDir::new().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion src/fallback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
//! assert!(result.valid);
//! ```
//!
//! size-waiver: domain bulk, see #1376. Multi-strategy edit recovery chain is one conceptual unit.
//! size-waiver: domain bulk, see #1408. Multi-strategy edit recovery chain is one conceptual unit.

use serde::{Deserialize, Serialize};

Expand Down
2 changes: 1 addition & 1 deletion src/ops/doc/yaml_splice.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// size-waiver: domain bulk, see #1376. YAML comment-preserving splice is cohesive domain logic; further split risks dual writers.
// size-waiver: domain bulk, see #1408. YAML comment-preserving splice is cohesive domain logic; further split risks dual writers.
/// Check if there are any arrays that grew between `old` and `new`.
pub(super) fn has_array_growth_diffs(old: &serde_json::Value, new: &serde_json::Value) -> bool {
if old == new {
Expand Down
82 changes: 82 additions & 0 deletions tests/integration/create_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,3 +603,85 @@ fn test_create_default_mode_exits_2() {
"file should not be created in default (preview) mode"
);
}

// ---------------------------------------------------------------------------
// --contain on create (engine-backed path) (#1406)
// ---------------------------------------------------------------------------

#[test]
fn test_create_contain_rejects_parent_escape() {
let dir = TempDir::new().unwrap();

Command::cargo_bin("patchloom")
.unwrap()
.args(["--cwd"])
.arg(dir.path())
.args([
"--contain",
"create",
"../escape-outside.txt",
"--content",
"nope\n",
"--apply",
])
.assert()
.failure()
.stderr(
predicate::str::contains("escapes")
.or(predicate::str::contains("rejected"))
.or(predicate::str::contains("workspace guard")),
);

assert!(!dir.path().join("../escape-outside.txt").exists());
}

#[test]
fn test_create_without_contain_allows_parent_escape() {
let dir = TempDir::new().unwrap();
let name = format!(
"patchloom-create-escape-{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
);

Command::cargo_bin("patchloom")
.unwrap()
.args(["--cwd"])
.arg(dir.path())
.arg("create")
.arg(format!("../{name}"))
.args(["--content", "escaped\n", "--apply"])
.assert()
.code(0);

let outside = dir.path().parent().unwrap().join(&name);
assert_eq!(fs::read_to_string(&outside).unwrap(), "escaped\n");
let _ = fs::remove_file(&outside);
}

#[test]
fn test_create_contain_allows_in_workspace() {
let dir = TempDir::new().unwrap();

Command::cargo_bin("patchloom")
.unwrap()
.args(["--cwd"])
.arg(dir.path())
.args([
"--contain",
"create",
"inside.txt",
"--content",
"ok\n",
"--apply",
])
.assert()
.code(0);

assert_eq!(
fs::read_to_string(dir.path().join("inside.txt")).unwrap(),
"ok\n"
);
}
13 changes: 8 additions & 5 deletions tests/integration/module_hygiene_tests.rs
Loading
Loading