mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 20:08:04 -10:00
fix(dsh): close overlay symlink TOCTOU and Windows .git aliases (CW-8056)
- Verify every path component and target via pinned non-following handles (O_NOFOLLOW/openat with fstatat AT_SYMLINK_NOFOLLOW ENOTDIR-to-ELOOP normalization on Unix and open_confined_dir_fd/read_confined_dir) across WorkspaceFile, list_dir, read_file, and write_file so mid-approval symlink swaps into .git/hooks or outside the workspace fail closed even under trust/follow flags (strict_workspace_confinement + CodemodeInvoker::for_extension).
- Reject Windows Alternate Data Streams (colons outside drive prefixes), trailing dots/spaces, and 8.3 short-name tilde aliases (such as GIT~1 and CODEWH~1), and canonicalize existing resolved paths on Windows.
- Check the caller's git_review_scope deny rule via action=review_scope before requesting approval on read-only DSH git exec, and add regression tests for symlink TOCTOU, strict trust escape, Windows alias/ADS validation, early git_review_scope denial, and real git review_scope exec queries.
Checks & Reviews:
- Pre-rebase Boat xlarge (farm-da023a): nextest codewhale-engine --lib filtered (313/313 passed, 6964 skipped, 20261114T033402Z-cw8056-eng4-594d3d)
- Pre-rebase Boat xlarge (farm-975bf7): nextest codewhale-util --lib (53/53 passed, 20261114T035452Z-cw8056-util4-0755be)
- Pre-rebase Boat xlarge (farm-975bf7): clippy -p codewhale-util -p codewhale-engine --lib --tests -- -D warnings (pass, 20261114T033426Z-cw8056-clippy4-1da3be)
- Post-rebase Boat xlarge on 47e716124c (farm-b6a592): nextest filtered (14/14 passed, 7331 skipped, 20261114T040720Z-cw8056-land-0149fa)
- Local: cargo fmt --all -- --check (pass), sh scripts/preflight.sh --check (preflight: OK)
- Dual pro independent reviews: aa01e0c3 (Correctness, Verdict LAND), 2d6ef94f (Attacker, Verdict LAND)
This commit is contained in:
1 parent
47e716124c
commit
fa0c649edf
10 files changed
+1867
-185
No files matched your search
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
|
||||
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
|
||||
- **Extension workspace confinement:** plugin workspace file reads, listings, and overlay writes now verify pinned ancestors and non-linked targets at the point of use without letting session trust flags escape the workspace, reject Windows alternate data streams and 8.3 short-name aliases for protected metadata directories, and check caller deny rules for git review queries before requesting approval. (CW-8056)
|
||||
- **Delayed-message workspaces and shell stdin approvals:** `send_later` `schedule` now keeps its workspace inside the session's reachable paths instead of accepting an arbitrary directory, and `send_later` approval prompts show the requested workspace; session shell grants now match exact inputs when `stdin` (`stdin`, `input`, `data`) is supplied instead of reusing a command-only grant across different `stdin`.
|
||||
|
||||
- **Installers no longer say Computer Use comes bundled:** the macOS install script and the install guide no longer print or state that Computer Use ships with the binary or needs Node.js 20. Computer Use is released separately and stays off until you turn it on.
|
||||
|
||||
@@ -3352,6 +3352,447 @@ async fn dsh_workspace_arm_reads_writes_and_runs_literal_git_only_through_the_ga
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn dsh_workspace_overlay_closes_mid_approval_symlink_toctou_and_trust_mode() {
|
||||
use super::dsh_broker::{TABLE, TestGrants};
|
||||
use super::dsh_workspace::TestOverlay;
|
||||
use super::protocol::DshService;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace]);
|
||||
let mut f = fixture(true);
|
||||
let workspace = f.context.workspace.canonicalize().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let hooks_dir = workspace.join(".git/hooks");
|
||||
std::fs::create_dir_all(&hooks_dir).unwrap();
|
||||
std::fs::create_dir_all(workspace.join("notes")).unwrap();
|
||||
std::fs::create_dir_all(workspace.join("stage")).unwrap();
|
||||
let hook = hooks_dir.join("pre-commit");
|
||||
std::fs::write(&hook, "clean-hook\n").unwrap();
|
||||
let outside_target = outside.path().join("outside.txt");
|
||||
std::fs::write(&outside_target, "clean-outside\n").unwrap();
|
||||
let existing = workspace.join("notes/existing.txt");
|
||||
std::fs::write(&existing, "before\n").unwrap();
|
||||
let existing_hash = crate::tools::file::content_hash(b"before\n");
|
||||
|
||||
let specs = crate::tools::registry::ToolRegistryBuilder::new()
|
||||
.with_file_tools()
|
||||
.with_git_tools()
|
||||
.build(ToolContext::new(&workspace))
|
||||
.all();
|
||||
f.gate = f.gate.clone().for_extension(f.caller(), specs);
|
||||
f.context.trust_mode = true;
|
||||
f.context.follow_symlinks = true;
|
||||
f.context
|
||||
.trusted_external_paths
|
||||
.push(outside.path().to_path_buf());
|
||||
f.in_session("workspace-toctou-session");
|
||||
let guard = f.begin(7, &f.owner);
|
||||
let ticket = broker_ticket(&guard, "workspace").unwrap();
|
||||
let _overlay = TestOverlay::granted();
|
||||
|
||||
f.server.hold.store(true, Ordering::SeqCst);
|
||||
|
||||
{
|
||||
let target = workspace.join("notes/new_leaf.txt");
|
||||
let hook_target = hook.clone();
|
||||
let expected_asked = 1;
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(
|
||||
f.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"write",
|
||||
json!({"path": "notes/new_leaf.txt", "content": "pwned\n"}),
|
||||
),
|
||||
async {
|
||||
while f.server.asked.load(Ordering::SeqCst) < expected_asked {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
std::os::unix::fs::symlink(&hook_target, &target).unwrap();
|
||||
f.server.release.notify_one();
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("leaf swap test timed out");
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let stage_dir = workspace.join("stage");
|
||||
let hooks_target = hooks_dir.clone();
|
||||
let expected_asked = 2;
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(
|
||||
f.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"write",
|
||||
json!({"path": "stage/swapped-hook", "content": "pwned\n"}),
|
||||
),
|
||||
async {
|
||||
while f.server.asked.load(Ordering::SeqCst) < expected_asked {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
std::fs::remove_dir(&stage_dir).unwrap();
|
||||
std::os::unix::fs::symlink(&hooks_target, &stage_dir).unwrap();
|
||||
f.server.release.notify_one();
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("dir swap test timed out");
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let existing_path = existing.clone();
|
||||
let hook_target = hook.clone();
|
||||
let expected_asked = 3;
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(
|
||||
f.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"write",
|
||||
json!({
|
||||
"path": "notes/existing.txt",
|
||||
"content": "pwned\n",
|
||||
"expected_hash": existing_hash,
|
||||
}),
|
||||
),
|
||||
async {
|
||||
while f.server.asked.load(Ordering::SeqCst) < expected_asked {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
std::fs::remove_file(&existing_path).unwrap();
|
||||
std::os::unix::fs::symlink(&hook_target, &existing_path).unwrap();
|
||||
f.server.release.notify_one();
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("existing replace swap test timed out");
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let trust_target = workspace.join("notes/trust_escape.txt");
|
||||
let outside_copy = outside_target.clone();
|
||||
let expected_asked = 4;
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(
|
||||
f.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"write",
|
||||
json!({"path": "notes/trust_escape.txt", "content": "pwned\n"}),
|
||||
),
|
||||
async {
|
||||
while f.server.asked.load(Ordering::SeqCst) < expected_asked {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
std::os::unix::fs::symlink(&outside_copy, &trust_target).unwrap();
|
||||
f.server.release.notify_one();
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("trust escape swap test timed out");
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
std::fs::write(workspace.join("notes/swap_read.txt"), "safe\n").unwrap();
|
||||
let swap_read = workspace.join("notes/swap_read.txt");
|
||||
let outside_copy = outside_target.clone();
|
||||
let expected_asked = 5;
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(
|
||||
f.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"read",
|
||||
json!({"path": "notes/swap_read.txt"}),
|
||||
),
|
||||
async {
|
||||
while f.server.asked.load(Ordering::SeqCst) < expected_asked {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
std::fs::remove_file(&swap_read).unwrap();
|
||||
std::os::unix::fs::symlink(&outside_copy, &swap_read).unwrap();
|
||||
f.server.release.notify_one();
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("read swap test timed out");
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
std::fs::create_dir_all(workspace.join("list_stage")).unwrap();
|
||||
let list_dir_path = workspace.join("list_stage");
|
||||
let outside_dir_copy = outside.path().to_path_buf();
|
||||
let expected_asked = 6;
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(
|
||||
f.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"list",
|
||||
json!({"path": "list_stage"}),
|
||||
),
|
||||
async {
|
||||
while f.server.asked.load(Ordering::SeqCst) < expected_asked {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
std::fs::remove_dir(&list_dir_path).unwrap();
|
||||
std::os::unix::fs::symlink(&outside_dir_copy, &list_dir_path).unwrap();
|
||||
f.server.release.notify_one();
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("list swap test timed out");
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
}
|
||||
|
||||
f.server.hold.store(false, Ordering::SeqCst);
|
||||
|
||||
std::fs::hard_link(&hook, workspace.join("notes/hard_hook")).unwrap();
|
||||
let hard_result = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"write",
|
||||
json!({"path": "notes/hard_hook", "content": "pwned\n"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&hard_result),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
|
||||
assert_eq!(std::fs::read_to_string(&hook).unwrap(), "clean-hook\n");
|
||||
assert!(!hooks_dir.join("swapped-hook").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&outside_target).unwrap(),
|
||||
"clean-outside\n"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_workspace_exec_checks_git_review_scope_denial_before_gate() {
|
||||
use super::dsh_broker::{TABLE, TestGrants};
|
||||
use super::protocol::DshService;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace]);
|
||||
let mut f = fixture(true);
|
||||
let workspace = f.context.workspace.canonicalize().unwrap();
|
||||
let specs = crate::tools::registry::ToolRegistryBuilder::new()
|
||||
.with_git_tools()
|
||||
.build(ToolContext::new(&workspace))
|
||||
.all();
|
||||
f.gate = f.gate.clone().for_extension(f.caller(), specs);
|
||||
f.context.disallowed_tools = vec!["git_review_scope".into()];
|
||||
f.in_session("workspace-git-deny-session");
|
||||
let guard = f.begin(7, &f.owner);
|
||||
let ticket = broker_ticket(&guard, "workspace").unwrap();
|
||||
|
||||
let denied = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"exec",
|
||||
json!({"argv": ["git", "rev-parse", "--show-toplevel"]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&denied),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
assert_eq!(
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
0,
|
||||
"git_review_scope denial must stop before the gate"
|
||||
);
|
||||
drop(guard);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_workspace_exec_runs_real_git_review_scope_queries() {
|
||||
use super::dsh_broker::{TABLE, TestGrants};
|
||||
use super::protocol::DshService;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace]);
|
||||
let mut f = fixture(true);
|
||||
let workspace = f.context.workspace.canonicalize().unwrap();
|
||||
let run_git = |args: &[&str]| {
|
||||
std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&workspace)
|
||||
.output()
|
||||
};
|
||||
let Ok(init) = run_git(&["init", "-q"]) else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
init.status.success(),
|
||||
"{:?}",
|
||||
String::from_utf8_lossy(&init.stderr)
|
||||
);
|
||||
assert!(
|
||||
run_git(&["config", "user.email", "[email protected]"])
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
run_git(&["config", "user.name", "Tester"])
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
std::fs::write(workspace.join("base.txt"), "one\n").unwrap();
|
||||
assert!(run_git(&["add", "base.txt"]).unwrap().status.success());
|
||||
assert!(
|
||||
run_git(&["commit", "-q", "-m", "first commit"])
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
std::fs::create_dir_all(workspace.join("src")).unwrap();
|
||||
std::fs::write(workspace.join("src/a.txt"), "two\n").unwrap();
|
||||
assert!(run_git(&["add", "src/a.txt"]).unwrap().status.success());
|
||||
assert!(
|
||||
run_git(&["commit", "-q", "-m", "second commit"])
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
|
||||
let specs = crate::tools::registry::ToolRegistryBuilder::new()
|
||||
.with_git_tools()
|
||||
.build(ToolContext::new(&workspace))
|
||||
.all();
|
||||
f.gate = f.gate.clone().for_extension(f.caller(), specs);
|
||||
f.in_session("workspace-git-exec-session");
|
||||
let guard = f.begin(7, &f.owner);
|
||||
let ticket = broker_ticket(&guard, "workspace").unwrap();
|
||||
|
||||
let resolve_out = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"exec",
|
||||
json!({"argv": ["git", "rev-parse", "--verify", "--end-of-options", "HEAD"]}),
|
||||
)
|
||||
.await
|
||||
.expect("resolve HEAD");
|
||||
assert_eq!(resolve_out["ok"], true, "{resolve_out}");
|
||||
assert_eq!(resolve_out["exit_code"], 0, "{resolve_out}");
|
||||
let head_sha = resolve_out["stdout"].as_str().unwrap().trim().to_string();
|
||||
assert!(matches!(head_sha.len(), 40 | 64), "{head_sha}");
|
||||
|
||||
let merge_range = format!("{head_sha}...{head_sha}");
|
||||
for (argv, expected_substr) in [
|
||||
(vec!["git", "rev-parse", "--show-toplevel"], None),
|
||||
(
|
||||
vec!["git", "log", "-n", "100", "--format=%H%x09%s"],
|
||||
Some("second commit"),
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"git",
|
||||
"--no-optional-locks",
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"refs/heads",
|
||||
],
|
||||
None,
|
||||
),
|
||||
(
|
||||
vec!["git", "merge-base", head_sha.as_str(), head_sha.as_str()],
|
||||
None,
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"git",
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
merge_range.as_str(),
|
||||
],
|
||||
Some("0\t0"),
|
||||
),
|
||||
] {
|
||||
let out = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"exec",
|
||||
json!({"argv": argv}),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("argv={argv:?}: {err:?}"));
|
||||
assert_eq!(out["ok"], true, "argv={argv:?}: {out}");
|
||||
assert_eq!(out["exit_code"], 0, "argv={argv:?}: {out}");
|
||||
if let Some(substr) = expected_substr {
|
||||
assert!(
|
||||
out["stdout"].as_str().unwrap().contains(substr),
|
||||
"expected {substr} in {out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum ActMethod {
|
||||
|
||||
@@ -7,14 +7,17 @@ use serde_json::{Value, json};
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::execution_envelope::enforce_tool_denial;
|
||||
use crate::tools::spec::{ToolContext, resolve_strict_authority_path};
|
||||
use crate::tools::spec::{
|
||||
ToolContext, has_forbidden_windows_colon, has_windows_drive_or_unc_prefix,
|
||||
is_protected_workspace_metadata_segment, resolve_strict_authority_path,
|
||||
strip_workspace_root_str, validate_windows_relative_segments,
|
||||
};
|
||||
|
||||
const MAX_PATH_BYTES: usize = 512;
|
||||
const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_READ_LINES: u64 = 10_000;
|
||||
const MAX_ARGS: usize = 8;
|
||||
const MAX_ARG_BYTES: usize = 1024;
|
||||
const PROTECTED: &[&str] = &[".git", ".codewhale"];
|
||||
const LOG: &str = "codewhale::dsh_workspace";
|
||||
|
||||
static CONFINE_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
@@ -96,7 +99,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
"max_lines is between 1 and {MAX_READ_LINES}"
|
||||
)));
|
||||
}
|
||||
let tool = caller(&call, "read_file")?;
|
||||
let tool = caller(&call, "read_file", &json!({}))?;
|
||||
let (shown, resolved) = confine(&call, &tool, &path, Access::Read).await?;
|
||||
let mut input = json!({ "path": resolved });
|
||||
if let Some(start_line) = start_line {
|
||||
@@ -110,7 +113,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::List(ListInput { path }) => {
|
||||
let tool = caller(&call, "list_dir")?;
|
||||
let tool = caller(&call, "list_dir", &json!({}))?;
|
||||
let raw = path.as_deref().unwrap_or(".");
|
||||
let (shown, resolved) = confine(&call, &tool, raw, Access::Read).await?;
|
||||
nested(&call, "list_dir", json!({ "path": resolved }))
|
||||
@@ -137,7 +140,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "workspace write refused: no reviewed overlay grant");
|
||||
return Err(refused("this plugin may not write to the workspace"));
|
||||
}
|
||||
let tool = caller(&call, "write_file")?;
|
||||
let tool = caller(&call, "write_file", &json!({}))?;
|
||||
let (shown, resolved) = confine(&call, &tool, &path, Access::Overlay).await?;
|
||||
let mut input = json!({ "path": resolved, "content": content });
|
||||
if let Some(expected_hash) = expected_hash {
|
||||
@@ -148,8 +151,9 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::Exec(ExecInput { argv, cwd }) => {
|
||||
let query = literal_git(&argv)?;
|
||||
let tool = caller(&call, "Git")?;
|
||||
let mut input = literal_git(&argv)?;
|
||||
input["action"] = json!("review_scope");
|
||||
let tool = caller(&call, "Git", &input)?;
|
||||
if let Some(cwd) = cwd.as_deref() {
|
||||
let (shown, _) = confine(&call, &tool, cwd, Access::Read).await?;
|
||||
if shown != "." {
|
||||
@@ -157,8 +161,6 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
return Err(refused("git runs only at the workspace root"));
|
||||
}
|
||||
}
|
||||
let mut input = query;
|
||||
input["action"] = json!("review_scope");
|
||||
nested(&call, "Git", input).await.map(exec_outcome)
|
||||
}
|
||||
}
|
||||
@@ -212,10 +214,14 @@ fn overlay_granted() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
|
||||
fn caller(
|
||||
call: &BrokerCall,
|
||||
tool_name: &'static str,
|
||||
input: &Value,
|
||||
) -> Result<ToolContext, BrokerRefusal> {
|
||||
let mut tool = call.context.tool().clone();
|
||||
let _ = tool.refresh_live_posture();
|
||||
if enforce_tool_denial(&tool, tool_name, &json!({})).is_err() {
|
||||
if enforce_tool_denial(&tool, tool_name, input).is_err() {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, tool = tool_name, "workspace refused: the caller may not use this tool");
|
||||
return Err(refused(
|
||||
"this workspace call is not available to this caller",
|
||||
@@ -224,23 +230,17 @@ fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, Bro
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
fn relative(workspace: &Path, raw: &str) -> Option<String> {
|
||||
fn relative_for(workspace: &Path, canonical: &Path, raw: &str, windows: bool) -> Option<String> {
|
||||
let path = Path::new(raw);
|
||||
if !path.is_absolute() {
|
||||
let is_absolute = path.is_absolute()
|
||||
|| raw.starts_with('/')
|
||||
|| (windows && (raw.starts_with('\\') || has_windows_drive_or_unc_prefix(raw)));
|
||||
if !is_absolute {
|
||||
return Some(raw.to_string());
|
||||
}
|
||||
let canonical = workspace.canonicalize().ok();
|
||||
let rest = path.strip_prefix(workspace).ok().or_else(|| {
|
||||
canonical
|
||||
.as_deref()
|
||||
.and_then(|root| path.strip_prefix(root).ok())
|
||||
})?;
|
||||
let rest = rest.to_str()?;
|
||||
Some(if rest.is_empty() {
|
||||
".".into()
|
||||
} else {
|
||||
rest.into()
|
||||
})
|
||||
let ws_str = workspace.to_str()?;
|
||||
let canonical_str = canonical.to_str();
|
||||
strip_workspace_root_str(ws_str, canonical_str, raw, windows)
|
||||
}
|
||||
|
||||
fn shape(raw: &str) -> Result<(), BrokerRefusal> {
|
||||
@@ -256,18 +256,37 @@ fn shape(raw: &str) -> Result<(), BrokerRefusal> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn protected(relative: &str) -> bool {
|
||||
relative.replace('\\', "/").split('/').any(|segment| {
|
||||
PROTECTED
|
||||
.iter()
|
||||
.any(|name| segment.eq_ignore_ascii_case(name))
|
||||
})
|
||||
fn protected_for(relative: &str, windows: bool) -> bool {
|
||||
relative
|
||||
.replace('\\', "/")
|
||||
.split('/')
|
||||
.any(|segment| is_protected_workspace_metadata_segment(segment, windows))
|
||||
}
|
||||
|
||||
fn resolve(tool: &ToolContext, raw: &str, access: Access) -> Result<(String, PathBuf), String> {
|
||||
let relative = relative(&tool.workspace, raw).ok_or_else(|| "outside".to_string())?;
|
||||
let resolved = resolve_strict_authority_path(tool, &relative).map_err(|e| e.to_string())?;
|
||||
resolve_for(tool, raw, access, cfg!(windows))
|
||||
}
|
||||
|
||||
fn resolve_for(
|
||||
tool: &ToolContext,
|
||||
raw: &str,
|
||||
access: Access,
|
||||
windows: bool,
|
||||
) -> Result<(String, PathBuf), String> {
|
||||
if has_forbidden_windows_colon(raw, windows) {
|
||||
return Err("windows colons and alternate data streams are not allowed".to_string());
|
||||
}
|
||||
let root = tool.workspace.canonicalize().map_err(|e| e.to_string())?;
|
||||
let relative =
|
||||
relative_for(&tool.workspace, &root, raw, windows).ok_or_else(|| "outside".to_string())?;
|
||||
validate_windows_relative_segments(&relative, windows)?;
|
||||
let resolved = resolve_strict_authority_path(tool, &relative).map_err(|e| e.to_string())?;
|
||||
#[cfg(windows)]
|
||||
let resolved = if resolved.exists() {
|
||||
resolved.canonicalize().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
resolved
|
||||
};
|
||||
let shown = resolved
|
||||
.strip_prefix(&root)
|
||||
.map_err(|_| "outside".to_string())?
|
||||
@@ -275,7 +294,8 @@ fn resolve(tool: &ToolContext, raw: &str, access: Access) -> Result<(String, Pat
|
||||
.ok_or_else(|| "not UTF-8".to_string())?
|
||||
.replace('\\', "/");
|
||||
let shown = if shown.is_empty() { ".".into() } else { shown };
|
||||
if access == Access::Overlay && (shown == "." || protected(&shown)) {
|
||||
validate_windows_relative_segments(&shown, windows)?;
|
||||
if access == Access::Overlay && (shown == "." || protected_for(&shown, windows)) {
|
||||
return Err(format!("overlay excludes {shown}"));
|
||||
}
|
||||
Ok((shown, resolved))
|
||||
@@ -685,6 +705,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_metadata_aliases_short_names_and_ads_are_refused_in_validation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/a.txt"), "a").unwrap();
|
||||
let tool = ToolContext::new(&root);
|
||||
|
||||
for alias in [
|
||||
"GIT~1",
|
||||
"GIT~1/hooks/pre-commit",
|
||||
"git~12/config",
|
||||
"sub/GIT~2/HEAD",
|
||||
"CODEWH~1",
|
||||
"CODEWH~1/config.json",
|
||||
"codew~1/auth.json",
|
||||
"code~1/sessions",
|
||||
"SUBDIR~1/file.txt",
|
||||
".git::$INDEX_ALLOCATION",
|
||||
".GIT::$INDEX_ALLOCATION",
|
||||
"sub/.git:stream",
|
||||
"sub/.codewhale:secret",
|
||||
"file.txt:Zone.Identifier",
|
||||
"C:rel.txt",
|
||||
".git.",
|
||||
".git /config",
|
||||
".codewhale.",
|
||||
"sub/.codewhale /x",
|
||||
] {
|
||||
assert!(
|
||||
resolve_for(&tool, alias, Access::Read, true).is_err(),
|
||||
"read must reject {alias}"
|
||||
);
|
||||
assert!(
|
||||
resolve_for(&tool, alias, Access::Overlay, true).is_err(),
|
||||
"overlay must reject {alias}"
|
||||
);
|
||||
}
|
||||
|
||||
for metadata in [
|
||||
"GIT~1/hooks/pre-commit",
|
||||
"git~1",
|
||||
"CODEWH~1/config.json",
|
||||
"codew~2",
|
||||
".git::$INDEX_ALLOCATION",
|
||||
"sub/.git:stream",
|
||||
"sub/.codewhale:secret",
|
||||
".git.",
|
||||
".git ",
|
||||
".codewhale.",
|
||||
".codewhale ",
|
||||
] {
|
||||
assert!(
|
||||
protected_for(metadata, true),
|
||||
"protected_for must catch {metadata}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resolve_for(&tool, "src/a.txt", Access::Overlay, true)
|
||||
.unwrap()
|
||||
.0,
|
||||
"src/a.txt"
|
||||
);
|
||||
assert!(has_forbidden_windows_colon(".git::$INDEX_ALLOCATION", true));
|
||||
assert!(has_forbidden_windows_colon("sub/.git:stream", true));
|
||||
assert!(has_forbidden_windows_colon("C:rel.txt", true));
|
||||
assert!(!has_forbidden_windows_colon(r"C:\ws\src\a.txt", true));
|
||||
assert!(!has_forbidden_windows_colon(r"\\?\C:\ws\src\a.txt", true));
|
||||
|
||||
assert_eq!(
|
||||
strip_workspace_root_str(r"C:\ws", Some(r"\\?\C:\ws"), r"\\?\C:\ws\src\a.txt", true)
|
||||
.as_deref(),
|
||||
Some("src/a.txt")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_workspace_root_str(r"C:\ws", None, r"c:/ws/src/a.txt", true).as_deref(),
|
||||
Some("src/a.txt")
|
||||
);
|
||||
assert!(
|
||||
has_forbidden_windows_colon(r"C:\ws\.git::$INDEX_ALLOCATION", true),
|
||||
"drive prefix must not exempt trailing colons"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_are_typed_projections() {
|
||||
let read = file_outcome(
|
||||
|
||||
@@ -730,6 +730,10 @@ impl CodemodeInvoker {
|
||||
extra_refusal: ExtraRefusal,
|
||||
) -> Self {
|
||||
context.execution.nested_call_gate = None;
|
||||
context.execution.strict_workspace_confinement = true;
|
||||
context.execution.trust_mode = false;
|
||||
context.execution.follow_symlinks = false;
|
||||
context.execution.trusted_external_paths.clear();
|
||||
Self::with_gate(
|
||||
specs,
|
||||
context,
|
||||
|
||||
+604
-45
@@ -47,15 +47,13 @@ pub fn content_hash(bytes: &[u8]) -> String {
|
||||
/// never has the full contents to hash. Digesting through a separate streaming
|
||||
/// pass keeps that memory bound while still producing a hash over the entire
|
||||
/// file — the only value an edit guard can verify against.
|
||||
fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
fn hash_reader_streaming(reader: &mut impl std::io::Read) -> std::io::Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buf)?;
|
||||
let read = reader.read(&mut buf)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -67,6 +65,11 @@ fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
hash_reader_streaming(&mut file)
|
||||
}
|
||||
|
||||
/// The one-line header that reports a snapshot hash to the model.
|
||||
///
|
||||
/// This goes in `ToolResult::content`, not in `ToolResult::metadata`, and that
|
||||
@@ -683,6 +686,196 @@ async fn run_blocking_write_atomic(path: &Path, contents: Vec<u8>) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ConfinedFileId {
|
||||
#[cfg(unix)]
|
||||
Unix { dev: u64, ino: u64 },
|
||||
#[cfg(windows)]
|
||||
Windows { volume: u32, index: u64 },
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
fn confined_file_id(file: &fs::File) -> std::io::Result<ConfinedFileId> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let meta = file.metadata()?;
|
||||
Ok(ConfinedFileId::Unix {
|
||||
dev: meta.dev(),
|
||||
ino: meta.ino(),
|
||||
})
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let identity = codewhale_util::path_identity::windows_file_identity(file)?;
|
||||
Ok(ConfinedFileId::Windows {
|
||||
volume: identity.volume,
|
||||
index: identity.index,
|
||||
})
|
||||
}
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
{
|
||||
let _ = file;
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_confined_policy_error(error: &std::io::Error) -> bool {
|
||||
#[cfg(unix)]
|
||||
if error.raw_os_error() == Some(libc::ELOOP) {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::InvalidInput
|
||||
| std::io::ErrorKind::InvalidData
|
||||
| std::io::ErrorKind::AlreadyExists
|
||||
| std::io::ErrorKind::PermissionDenied
|
||||
)
|
||||
}
|
||||
|
||||
fn map_confined_io_error(error: std::io::Error, path: &Path, action: &str) -> ToolError {
|
||||
if is_confined_policy_error(&error) {
|
||||
return ToolError::permission_denied(format!(
|
||||
"{action} refused symlink or confined path constraint for {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
ToolError::execution_failed(format!("Failed to {action} {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
enum ConfinedPrior {
|
||||
Missing,
|
||||
Existing { bytes: Vec<u8>, id: ConfinedFileId },
|
||||
}
|
||||
|
||||
fn write_uses_confined_io(context: &ToolContext) -> bool {
|
||||
context.strict_workspace_confinement
|
||||
|| (!context.trust_mode
|
||||
&& !context.follow_symlinks
|
||||
&& context.trusted_external_paths.is_empty())
|
||||
}
|
||||
|
||||
async fn resolve_confined_target_async(
|
||||
context: &ToolContext,
|
||||
raw: &str,
|
||||
allow_root_dot: bool,
|
||||
) -> Result<(PathBuf, PathBuf), ToolError> {
|
||||
let workspace_canonical =
|
||||
tokio::fs::canonicalize(&context.workspace)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to canonicalize workspace {}: {error}",
|
||||
context.workspace.display()
|
||||
))
|
||||
})?;
|
||||
let relative =
|
||||
super::spec::resolve_confined_target(context, &workspace_canonical, raw, allow_root_dot)?;
|
||||
Ok((workspace_canonical, relative))
|
||||
}
|
||||
|
||||
async fn load_confined_prior_file(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
max_bytes: Option<usize>,
|
||||
context: &ToolContext,
|
||||
) -> Result<ConfinedPrior, ToolError> {
|
||||
check_file_operation_cancelled(context)?;
|
||||
let workspace = workspace_canonical.to_path_buf();
|
||||
let relative = relative.to_path_buf();
|
||||
let display = display_path.to_path_buf();
|
||||
let cancel = context.cancel_token.clone();
|
||||
let mut worker = tokio::task::spawn_blocking(move || {
|
||||
use std::io::Read as _;
|
||||
check_contract_cancelled(cancel.as_ref())?;
|
||||
let ws =
|
||||
match codewhale_util::files::WorkspaceFile::open_shared(&workspace, &relative, false) {
|
||||
Ok(ws) => ws,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(ConfinedPrior::Missing);
|
||||
}
|
||||
Err(error) => return Err(map_confined_io_error(error, &display, "inspect")),
|
||||
};
|
||||
let mut file = match ws.open_file() {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(ConfinedPrior::Missing);
|
||||
}
|
||||
Err(error) => return Err(map_confined_io_error(error, &display, "read")),
|
||||
};
|
||||
let id = confined_file_id(&file)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "inspect"))?;
|
||||
let bytes = if let Some(cap) = max_bytes {
|
||||
let meta = file
|
||||
.metadata()
|
||||
.map_err(|error| map_confined_io_error(error, &display, "read"))?;
|
||||
if meta.len() > cap as u64 {
|
||||
check_contract_file_size(CONTRACT_FILE_MAX_BYTES + 1)?;
|
||||
}
|
||||
read_contract_source(&mut file, cancel.as_ref())?
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "read"))?;
|
||||
bytes
|
||||
};
|
||||
Ok(ConfinedPrior::Existing { bytes, id })
|
||||
});
|
||||
let result = if let Some(cancel) = context.cancel_token.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(ToolError::cancelled("Operation aborted")),
|
||||
result = &mut worker => result,
|
||||
}
|
||||
} else {
|
||||
worker.await
|
||||
};
|
||||
result.map_err(|error| ToolError::execution_failed(format!("File read task: {error}")))?
|
||||
}
|
||||
|
||||
async fn commit_confined_write(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
expected_id: Option<ConfinedFileId>,
|
||||
contents: Vec<u8>,
|
||||
) -> Result<(), ToolError> {
|
||||
let workspace = workspace_canonical.to_path_buf();
|
||||
let relative = relative.to_path_buf();
|
||||
let display = display_path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let create = expected_id.is_none();
|
||||
let ws = codewhale_util::files::WorkspaceFile::open_shared(&workspace, &relative, create)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
match expected_id {
|
||||
None => ws
|
||||
.publish(&contents)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write")),
|
||||
Some(prior_id) => {
|
||||
let probe = ws
|
||||
.identity_probe()
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
let current_id = confined_file_id(&probe)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
if current_id != prior_id {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"write target changed before replacement: {}",
|
||||
display.display()
|
||||
)));
|
||||
}
|
||||
drop(probe);
|
||||
ws.replace(&contents)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("File write task: {error}")))?
|
||||
}
|
||||
|
||||
fn check_file_operation_cancelled(context: &ToolContext) -> Result<(), ToolError> {
|
||||
check_contract_cancelled(context.cancel_token.as_ref())
|
||||
}
|
||||
@@ -1024,6 +1217,9 @@ impl ToolSpec for ReadFileTool {
|
||||
READ_PARAMS.reject_unknown(&input)?;
|
||||
|
||||
let path_str = required_str(&input, "path")?;
|
||||
if context.strict_workspace_confinement {
|
||||
return execute_confined_read(&input, path_str, context).await;
|
||||
}
|
||||
// S1/F2: raw spelling first, resolved path after — see the matching
|
||||
// comment in `execute_contract_read`. Only the raw-spelling denial can
|
||||
// promise an error that never names the symlink target's location.
|
||||
@@ -1163,7 +1359,7 @@ impl ToolSpec for ReadFileTool {
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let (window, total_lines) = read_window_streaming(file, start_line, max_lines)
|
||||
let (window, total_lines) = read_window_streaming(&file, start_line, max_lines)
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {}",
|
||||
@@ -1221,6 +1417,244 @@ impl ToolSpec for ReadFileTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_read_range(input: &Value) -> Result<(usize, usize), ToolError> {
|
||||
let start_line = match optional_u64(input, "start_line", 1)? {
|
||||
0 => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"start_line must be 1-based and greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
v => usize::try_from(v).map_err(|_| {
|
||||
ToolError::invalid_input("start_line exceeds platform addressable range".to_string())
|
||||
})?,
|
||||
};
|
||||
let max_lines = match optional_u64(input, "max_lines", DEFAULT_READ_LINES as u64)? {
|
||||
0 => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"max_lines must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
v => {
|
||||
let converted = usize::try_from(v).map_err(|_| {
|
||||
ToolError::invalid_input("max_lines exceeds platform addressable range".to_string())
|
||||
})?;
|
||||
std::cmp::min(converted, HARD_MAX_READ_LINES)
|
||||
}
|
||||
};
|
||||
Ok((start_line, max_lines))
|
||||
}
|
||||
|
||||
enum ConfinedReadOutput {
|
||||
Pdf {
|
||||
id: ConfinedFileId,
|
||||
},
|
||||
Image {
|
||||
id: ConfinedFileId,
|
||||
},
|
||||
Small(String),
|
||||
Window {
|
||||
window: Vec<String>,
|
||||
total_lines: usize,
|
||||
start_line: usize,
|
||||
max_lines: usize,
|
||||
hash: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_confined_pdf_handle(file: &mut fs::File, path: &Path) -> std::io::Result<bool> {
|
||||
use std::io::{Read as _, Seek as _};
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
let matched = file.read_exact(&mut buf).is_ok() && &buf == b"%PDF";
|
||||
file.seek(std::io::SeekFrom::Start(0))?;
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
async fn execute_confined_read(
|
||||
input: &Value,
|
||||
path_str: &str,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
enforce_read_denylist(Path::new(path_str), "read_file")?;
|
||||
let (workspace_canonical, relative) =
|
||||
resolve_confined_target_async(context, path_str, false).await?;
|
||||
let file_path = workspace_canonical.join(&relative);
|
||||
if is_codewhale_credential_path(&file_path) {
|
||||
return Err(ToolError::permission_denied(
|
||||
"read_file cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
enforce_read_denylist(&file_path, "read_file")?;
|
||||
let pages = optional_str(input, "pages")?;
|
||||
let explicit_range = input
|
||||
.get("start_line")
|
||||
.or_else(|| input.get("max_lines"))
|
||||
.is_some();
|
||||
|
||||
let output = tokio::task::spawn_blocking({
|
||||
let workspace_canonical = workspace_canonical.clone();
|
||||
let relative = relative.clone();
|
||||
let file_path = file_path.clone();
|
||||
let input_clone = input.clone();
|
||||
move || {
|
||||
use std::io::{Read as _, Seek as _};
|
||||
let ws = codewhale_util::files::WorkspaceFile::open_shared(
|
||||
&workspace_canonical,
|
||||
&relative,
|
||||
false,
|
||||
)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let mut file = ws
|
||||
.open_file_shared()
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let id = confined_file_id(&file)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
if is_confined_pdf_handle(&mut file, &file_path)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?
|
||||
{
|
||||
return Ok::<_, ToolError>(ConfinedReadOutput::Pdf { id });
|
||||
}
|
||||
if is_image_for_ocr(&file_path) {
|
||||
return Ok(ConfinedReadOutput::Image { id });
|
||||
}
|
||||
let file_bytes = file.metadata().map(|meta| meta.len()).unwrap_or(u64::MAX);
|
||||
if !explicit_range && file_bytes <= SMALL_FILE_BYTES as u64 {
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
return Ok(ConfinedReadOutput::Small(contents));
|
||||
}
|
||||
let (start_line, max_lines) = parse_read_range(&input_clone)?;
|
||||
let (window, total_lines) = read_window_streaming(&file, start_line, max_lines)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let hash = if file.seek(std::io::SeekFrom::Start(0)).is_ok() {
|
||||
hash_reader_streaming(&mut file).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ConfinedReadOutput::Window {
|
||||
window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash,
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("File read task: {e}")))??;
|
||||
|
||||
match output {
|
||||
ConfinedReadOutput::Pdf { id } => {
|
||||
verify_confined_probe(&workspace_canonical, &relative, &file_path, id, "read")?;
|
||||
if let Some(result) = read_pdf_if_detected(
|
||||
&file_path,
|
||||
pages,
|
||||
super::pdf::PdfTextCommand::system(Some(context)),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
Err(ToolError::execution_failed(format!(
|
||||
"Failed to read {}",
|
||||
file_path.display()
|
||||
)))
|
||||
}
|
||||
ConfinedReadOutput::Image { id } => {
|
||||
verify_confined_probe(&workspace_canonical, &relative, &file_path, id, "read")?;
|
||||
read_image_via_ocr(&file_path, path_str, context).await
|
||||
}
|
||||
ConfinedReadOutput::Small(contents) => {
|
||||
context.note_file_read(&file_path);
|
||||
let total_lines = contents.lines().count();
|
||||
if total_lines <= SMALL_FILE_LINES {
|
||||
let hash = content_hash(contents.as_bytes());
|
||||
let body = format!("{}{contents}", content_hash_header(&hash));
|
||||
return Ok(ToolResult::success(body).with_metadata(json!({
|
||||
"evidence_routing": "inline",
|
||||
"content_hash": hash
|
||||
})));
|
||||
}
|
||||
let hash = content_hash(contents.as_bytes());
|
||||
let window: Vec<String> = contents
|
||||
.lines()
|
||||
.take(DEFAULT_READ_LINES)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
Ok(render_line_window(
|
||||
path_str,
|
||||
&window,
|
||||
total_lines,
|
||||
1,
|
||||
DEFAULT_READ_LINES,
|
||||
Some(hash.as_str()),
|
||||
))
|
||||
}
|
||||
ConfinedReadOutput::Window {
|
||||
window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash,
|
||||
} => {
|
||||
context.note_file_read(&file_path);
|
||||
if start_line > total_lines {
|
||||
let hash_attr = hash
|
||||
.as_deref()
|
||||
.map(|hash| format!(" content_hash=\"{hash}\""))
|
||||
.unwrap_or_default();
|
||||
let output = format!(
|
||||
"<file path=\"{path_str}\" total_lines=\"{total_lines}\" shown_lines=\"none\" truncated=\"false\"{hash_attr}>\n\n[NO CONTENT] start_line {start_line} is beyond total_lines {total_lines}.\n</file>"
|
||||
);
|
||||
return Ok(ToolResult::success(output).with_metadata(json!({
|
||||
"evidence_routing": "inline",
|
||||
"content_hash": hash
|
||||
})));
|
||||
}
|
||||
Ok(render_line_window(
|
||||
path_str,
|
||||
&window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash.as_deref(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_confined_probe(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
expected_id: ConfinedFileId,
|
||||
action: &str,
|
||||
) -> Result<(), ToolError> {
|
||||
let ws =
|
||||
codewhale_util::files::WorkspaceFile::open_shared(workspace_canonical, relative, false)
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
let probe = ws
|
||||
.identity_probe()
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
let current_id = confined_file_id(&probe)
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
if current_id != expected_id {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"{action} target changed before inspection: {}",
|
||||
display_path.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Bounded output for large files. The small-file fast path keeps the
|
||||
// historical "return contents unchanged" behavior so existing flows
|
||||
// (small configs, single source files, etc.) don't suddenly start
|
||||
@@ -1246,7 +1680,7 @@ const SMALL_FILE_BYTES: usize = 16 * 1024;
|
||||
/// Returns the collected window plus the total line count. Only the window
|
||||
/// is ever held in memory.
|
||||
fn read_window_streaming(
|
||||
file: fs::File,
|
||||
file: &fs::File,
|
||||
start_line: usize,
|
||||
max_lines: usize,
|
||||
) -> std::io::Result<(Vec<String>, usize)> {
|
||||
@@ -1565,11 +1999,36 @@ impl WriteFileTool {
|
||||
let path_str = required_str(&input, "path")?;
|
||||
let file_content = required_str(&input, "content")?;
|
||||
check_contract_file_size(file_content.len())?;
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let confined_target = if write_uses_confined_io(context) {
|
||||
Some(resolve_confined_target_async(context, path_str, false).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let file_path = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => workspace_canonical.join(relative),
|
||||
None => context.resolve_path(path_str)?,
|
||||
};
|
||||
let mutation_guard = acquire_file_mutation(&file_path, context).await?;
|
||||
check_file_operation_cancelled(context)?;
|
||||
|
||||
let prior = load_contract_source(&file_path, false, context).await?;
|
||||
let (prior_id, prior) = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => match load_confined_prior_file(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
Some(CONTRACT_FILE_MAX_BYTES),
|
||||
context,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ConfinedPrior::Missing => (None, None),
|
||||
ConfinedPrior::Existing { bytes, id } => (Some(id), Some(bytes)),
|
||||
},
|
||||
None => (
|
||||
None,
|
||||
load_contract_source(&file_path, false, context).await?,
|
||||
),
|
||||
};
|
||||
let existed_before = prior.is_some();
|
||||
if existed_before {
|
||||
context.require_fresh_read_before_write(&file_path, path_str)?;
|
||||
@@ -1598,17 +2057,27 @@ impl WriteFileTool {
|
||||
}
|
||||
check_contract_file_size(written.len())?;
|
||||
check_file_operation_cancelled(context)?;
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
if let Some((workspace_canonical, relative)) = confined_target.as_ref() {
|
||||
commit_confined_write(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
prior_id,
|
||||
written.clone().into_bytes(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
check_file_operation_cancelled(context)?;
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
}
|
||||
check_file_operation_cancelled(context)?;
|
||||
// Once replacement starts, report its actual completion even if cancelled.
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
context.note_file_read(&file_path);
|
||||
drop(mutation_guard);
|
||||
|
||||
@@ -1700,29 +2169,61 @@ impl ToolSpec for WriteFileTool {
|
||||
let expected_hash = optional_str(&input, "expected_hash")?;
|
||||
let line_ending = requested_line_ending(&input)?;
|
||||
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let confined_target = if write_uses_confined_io(context) {
|
||||
Some(resolve_confined_target_async(context, path_str, false).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let file_path = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => workspace_canonical.join(relative),
|
||||
None => context.resolve_path(path_str)?,
|
||||
};
|
||||
let mutation_guard = acquire_file_mutation(&file_path, context).await?;
|
||||
|
||||
// Snapshot the existing contents (if any) before we overwrite — used
|
||||
// to render an inline diff in the tool result. Only a genuinely
|
||||
// absent path is "new": a stat that fails for any other reason must
|
||||
// not let an existing file be overwritten as if it were empty.
|
||||
let existed_before = tokio::fs::try_exists(&file_path).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to inspect {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
let prior_contents = if existed_before {
|
||||
tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
let (existed_before, prior_id, prior_contents) = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => {
|
||||
match load_confined_prior_file(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ConfinedPrior::Missing => (false, None, String::new()),
|
||||
ConfinedPrior::Existing { bytes, id } => {
|
||||
let contents = String::from_utf8(bytes).map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
(true, Some(id), contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let existed_before = tokio::fs::try_exists(&file_path).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
"Failed to inspect {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
String::new()
|
||||
})?;
|
||||
let prior_contents = if existed_before {
|
||||
tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
(existed_before, None, prior_contents)
|
||||
}
|
||||
};
|
||||
|
||||
// Content-hash guard (#3979), checked against the same snapshot the
|
||||
@@ -1758,16 +2259,28 @@ impl ToolSpec for WriteFileTool {
|
||||
if line_ending.is_some() {
|
||||
written = write_line_endings(&written, "", line_ending, true)?;
|
||||
}
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
if let Some((workspace_canonical, relative)) = confined_target.as_ref() {
|
||||
commit_confined_write(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
prior_id,
|
||||
written.clone().into_bytes(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
}
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
context.note_file_read(&file_path);
|
||||
drop(mutation_guard);
|
||||
|
||||
let display = file_path.display().to_string();
|
||||
let diff = make_unified_diff(&display, &prior_contents, &written);
|
||||
@@ -3436,6 +3949,52 @@ impl ToolSpec for ListDirTool {
|
||||
// raw spelling is checked first (F2) so the refusal names the caller's
|
||||
// path, never a symlink target it might resolve to.
|
||||
enforce_read_denylist(Path::new(path_str), "list_dir")?;
|
||||
if context.strict_workspace_confinement {
|
||||
let (workspace_canonical, relative) =
|
||||
resolve_confined_target_async(context, path_str, true).await?;
|
||||
let dir_path = if relative.as_os_str().is_empty() {
|
||||
workspace_canonical.clone()
|
||||
} else {
|
||||
workspace_canonical.join(&relative)
|
||||
};
|
||||
enforce_read_denylist(&dir_path, "list_dir")?;
|
||||
let worker_cancel_token = context.cancel_token.clone();
|
||||
let entries =
|
||||
run_blocking_list_dir(LIST_DIR_TIMEOUT, context.cancel_token.clone(), move || {
|
||||
check_list_dir_cancelled(worker_cancel_token.as_ref())?;
|
||||
let rel = (!relative.as_os_str().is_empty()).then_some(relative.as_path());
|
||||
let listing = codewhale_util::files::read_confined_dir(
|
||||
&workspace_canonical,
|
||||
rel,
|
||||
LIST_DIR_MAX_ENTRIES,
|
||||
)
|
||||
.map_err(|error| map_confined_io_error(error, &dir_path, "list"))?;
|
||||
check_list_dir_cancelled(worker_cancel_token.as_ref())?;
|
||||
let entries = listing
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
json!({
|
||||
"name": entry.name,
|
||||
"is_dir": entry.is_dir,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if listing.total_entries > entries.len() {
|
||||
Ok(json!({
|
||||
"entries": entries,
|
||||
"listed_entries": LIST_DIR_MAX_ENTRIES,
|
||||
"total_entries": listing.total_entries,
|
||||
"truncated": true,
|
||||
}))
|
||||
} else {
|
||||
Ok(Value::Array(entries))
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
return ToolResult::json(&entries)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()));
|
||||
}
|
||||
let dir_path = context.resolve_path(path_str)?;
|
||||
enforce_read_denylist(&dir_path, "list_dir")?;
|
||||
|
||||
|
||||
@@ -3317,3 +3317,95 @@ async fn read_file_refusal_names_home_spelling_not_denied_symlink_target() {
|
||||
denied_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn write_file_refuses_symlinks_to_git_hooks_and_strict_trust_escape() {
|
||||
let workspace_dir = tempfile::tempdir().expect("workspace");
|
||||
let outside_dir = tempfile::tempdir().expect("outside");
|
||||
let workspace = workspace_dir.path().canonicalize().expect("canonical ws");
|
||||
let hooks_dir = workspace.join(".git/hooks");
|
||||
fs::create_dir_all(&hooks_dir).expect("hooks dir");
|
||||
fs::create_dir_all(workspace.join("notes")).expect("notes dir");
|
||||
let hook = hooks_dir.join("pre-commit");
|
||||
fs::write(&hook, "clean-hook\n").expect("write hook");
|
||||
let outside_target = outside_dir.path().join("outside.txt");
|
||||
fs::write(&outside_target, "clean-outside\n").expect("write outside");
|
||||
|
||||
let link_leaf = workspace.join("notes/hook_link");
|
||||
std::os::unix::fs::symlink(&hook, &link_leaf).expect("symlink leaf");
|
||||
let link_dir = workspace.join("linked_hooks");
|
||||
std::os::unix::fs::symlink(&hooks_dir, &link_dir).expect("symlink dir");
|
||||
let hardlink_leaf = workspace.join("notes/hook_hard");
|
||||
fs::hard_link(&hook, &hardlink_leaf).expect("hard link leaf");
|
||||
|
||||
let ctx = ToolContext::new(&workspace);
|
||||
for bad in [
|
||||
"notes/hook_link",
|
||||
"linked_hooks/new-hook",
|
||||
"notes/hook_hard",
|
||||
] {
|
||||
let err = WriteFileTool
|
||||
.execute(json!({"path": bad, "content": "pwned\n"}), &ctx)
|
||||
.await
|
||||
.expect_err(bad);
|
||||
assert!(
|
||||
matches!(err, ToolError::PermissionDenied { .. }),
|
||||
"expected permission denied for {bad}, got {err:?}"
|
||||
);
|
||||
let contract_err =
|
||||
WriteFileTool::execute_contract_write(json!({"path": bad, "content": "pwned\n"}), &ctx)
|
||||
.await
|
||||
.expect_err(bad);
|
||||
assert!(
|
||||
matches!(contract_err, ToolError::PermissionDenied { .. }),
|
||||
"expected contract permission denied for {bad}, got {contract_err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut strict_trusted =
|
||||
ToolContext::with_options(&workspace, true, workspace.join("notes.md"));
|
||||
strict_trusted.execution.follow_symlinks = true;
|
||||
strict_trusted
|
||||
.execution
|
||||
.trusted_external_paths
|
||||
.push(outside_dir.path().to_path_buf());
|
||||
strict_trusted.execution.strict_workspace_confinement = true;
|
||||
|
||||
let escape_link = workspace.join("notes/escape_link");
|
||||
std::os::unix::fs::symlink(&outside_target, &escape_link).expect("escape symlink");
|
||||
let escape_dir = workspace.join("escape_dir");
|
||||
std::os::unix::fs::symlink(outside_dir.path(), &escape_dir).expect("escape dir");
|
||||
|
||||
for escaped in [
|
||||
"notes/escape_link",
|
||||
"escape_dir/created.txt",
|
||||
outside_target.to_str().expect("utf8 outside"),
|
||||
] {
|
||||
let err = WriteFileTool
|
||||
.execute(
|
||||
json!({"path": escaped, "content": "pwned\n"}),
|
||||
&strict_trusted,
|
||||
)
|
||||
.await
|
||||
.expect_err(escaped);
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolError::PermissionDenied { .. } | ToolError::PathEscape { .. }
|
||||
),
|
||||
"strict confinement must fail closed for {escaped}, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&hook).expect("read hook"),
|
||||
"clean-hook\n"
|
||||
);
|
||||
assert!(!hooks_dir.join("new-hook").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&outside_target).expect("read outside"),
|
||||
"clean-outside\n"
|
||||
);
|
||||
assert!(!outside_dir.path().join("created.txt").exists());
|
||||
}
|
||||
@@ -538,7 +538,9 @@ pub fn resolve_strict_authority_path(
|
||||
for segment in normalized.split('/') {
|
||||
current.push(segment);
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
Ok(metadata)
|
||||
if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) =>
|
||||
{
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"machine-readable authority paths must not traverse symlinks: {}",
|
||||
current.display()
|
||||
@@ -732,6 +734,7 @@ pub struct ToolExecutionState {
|
||||
pub live_posture: Option<crate::core::engine::LivePosture>,
|
||||
pub browser_tool: Option<String>,
|
||||
pub child_activation: Option<Arc<crate::tools::subagent::ChildActivationRequest>>,
|
||||
pub strict_workspace_confinement: bool,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ToolContext {
|
||||
@@ -837,6 +840,7 @@ impl ToolContext {
|
||||
live_posture: None,
|
||||
browser_tool: None,
|
||||
child_activation: None,
|
||||
strict_workspace_confinement: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1475,6 +1479,302 @@ pub fn normalize_path(path: &Path) -> PathBuf {
|
||||
normalized
|
||||
}
|
||||
|
||||
pub(crate) fn has_forbidden_windows_colon(raw: &str, windows: bool) -> bool {
|
||||
if !windows {
|
||||
return false;
|
||||
}
|
||||
let bytes = raw.as_bytes();
|
||||
let rest = if raw.starts_with(r"\\?\")
|
||||
|| raw.starts_with("//?/")
|
||||
|| raw.starts_with(r"\\.\")
|
||||
|| raw.starts_with("//./")
|
||||
{
|
||||
if bytes.get(4).is_some_and(u8::is_ascii_alphabetic)
|
||||
&& bytes.get(5) == Some(&b':')
|
||||
&& matches!(bytes.get(6), Some(b'\\') | Some(b'/'))
|
||||
{
|
||||
&raw[6..]
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
} else if bytes.first().is_some_and(u8::is_ascii_alphabetic)
|
||||
&& bytes.get(1) == Some(&b':')
|
||||
&& matches!(bytes.get(2), Some(b'\\') | Some(b'/'))
|
||||
{
|
||||
&raw[2..]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
rest.contains(':')
|
||||
}
|
||||
|
||||
pub(crate) fn is_windows_short_name_segment(segment: &str) -> bool {
|
||||
for (idx, _) in segment.match_indices('~') {
|
||||
let after = &segment[idx + 1..];
|
||||
let digits = after.split('.').next().unwrap_or(after);
|
||||
if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn has_windows_trailing_dot_or_space(segment: &str, windows: bool) -> bool {
|
||||
windows && segment != "." && segment != ".." && segment.ends_with(['.', ' '])
|
||||
}
|
||||
|
||||
pub(crate) fn is_protected_workspace_metadata_segment(segment: &str, windows: bool) -> bool {
|
||||
if codewhale_util::git_metadata::git_metadata_name(segment, windows) {
|
||||
return true;
|
||||
}
|
||||
let trimmed = if windows {
|
||||
segment
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches(['.', ' '])
|
||||
} else {
|
||||
segment
|
||||
};
|
||||
if trimmed.eq_ignore_ascii_case(".codewhale") {
|
||||
return true;
|
||||
}
|
||||
if windows {
|
||||
let stem = trimmed.split('.').next().unwrap_or(trimmed);
|
||||
if let Some((prefix, digits)) = stem.rsplit_once('~')
|
||||
&& !digits.is_empty()
|
||||
&& digits.bytes().all(|byte| byte.is_ascii_digit())
|
||||
{
|
||||
let prefix = prefix.strip_prefix('.').unwrap_or(prefix);
|
||||
let upper = prefix.to_ascii_uppercase();
|
||||
if !upper.is_empty() && "CODEWHALE".starts_with(&upper) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn validate_windows_relative_segments(
|
||||
relative: &str,
|
||||
windows: bool,
|
||||
) -> Result<(), String> {
|
||||
if !windows {
|
||||
return Ok(());
|
||||
}
|
||||
if has_forbidden_windows_colon(relative, true) {
|
||||
return Err("windows colons and alternate data streams are not allowed".to_string());
|
||||
}
|
||||
for segment in relative.replace('\\', "/").split('/') {
|
||||
if segment.is_empty() || segment == "." {
|
||||
continue;
|
||||
}
|
||||
if has_windows_trailing_dot_or_space(segment, true) {
|
||||
return Err(format!(
|
||||
"trailing dots or spaces are not allowed: {segment}"
|
||||
));
|
||||
}
|
||||
if is_windows_short_name_segment(segment) {
|
||||
return Err(format!(
|
||||
"windows short-name aliases are not allowed: {segment}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strip_windows_verbatim_slashes(raw: &str) -> String {
|
||||
let mut normalized = raw.replace('\\', "/");
|
||||
if let Some(rest) = normalized
|
||||
.strip_prefix("//?/UNC/")
|
||||
.or_else(|| normalized.strip_prefix("//?/unc/"))
|
||||
{
|
||||
normalized = format!("//{rest}");
|
||||
} else if let Some(rest) = normalized
|
||||
.strip_prefix("//?/")
|
||||
.or_else(|| normalized.strip_prefix("//./"))
|
||||
{
|
||||
normalized = rest.to_string();
|
||||
}
|
||||
while normalized.len() > 1 && normalized.ends_with('/') {
|
||||
if normalized.len() == 3 && normalized.as_bytes().get(1) == Some(&b':') {
|
||||
break;
|
||||
}
|
||||
normalized.pop();
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub(crate) fn has_windows_drive_or_unc_prefix(raw: &str) -> bool {
|
||||
raw.starts_with(r"\\")
|
||||
|| raw.starts_with("//")
|
||||
|| (raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
|
||||
&& raw.as_bytes().get(1) == Some(&b':'))
|
||||
}
|
||||
|
||||
pub(crate) fn strip_workspace_root_str(
|
||||
workspace_raw: &str,
|
||||
workspace_canonical: Option<&str>,
|
||||
raw: &str,
|
||||
windows: bool,
|
||||
) -> Option<String> {
|
||||
if !windows {
|
||||
let candidate = Path::new(raw);
|
||||
let rest = candidate
|
||||
.strip_prefix(Path::new(workspace_raw))
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
workspace_canonical.and_then(|root| candidate.strip_prefix(Path::new(root)).ok())
|
||||
})?;
|
||||
let mut clean = PathBuf::new();
|
||||
for component in rest.components() {
|
||||
match component {
|
||||
Component::Normal(part) => clean.push(part),
|
||||
Component::CurDir => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let shown = clean.to_str()?;
|
||||
return Some(if shown.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
shown.to_string()
|
||||
});
|
||||
}
|
||||
|
||||
let cand = strip_windows_verbatim_slashes(raw);
|
||||
for root in std::iter::once(workspace_raw).chain(workspace_canonical) {
|
||||
let root_norm = strip_windows_verbatim_slashes(root);
|
||||
if cand.eq_ignore_ascii_case(&root_norm) {
|
||||
return Some(".".to_string());
|
||||
}
|
||||
let cand_bytes = cand.as_bytes();
|
||||
let root_len = root_norm.len();
|
||||
if cand_bytes.len() > root_len
|
||||
&& cand[..root_len].eq_ignore_ascii_case(&root_norm)
|
||||
&& (root_norm.ends_with('/') || cand_bytes[root_len] == b'/')
|
||||
{
|
||||
let tail = &cand[if root_norm.ends_with('/') {
|
||||
root_len
|
||||
} else {
|
||||
root_len + 1
|
||||
}..];
|
||||
let mut segments = Vec::new();
|
||||
for segment in tail.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => return None,
|
||||
part => segments.push(part),
|
||||
}
|
||||
}
|
||||
return Some(if segments.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
segments.join("/")
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_confined_target(
|
||||
context: &ToolContext,
|
||||
workspace_canonical: &Path,
|
||||
raw: &str,
|
||||
allow_root_dot: bool,
|
||||
) -> Result<PathBuf, ToolError> {
|
||||
if context.workspace.as_os_str().is_empty() || raw.is_empty() {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if has_forbidden_windows_colon(raw, cfg!(windows)) {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot contain a colon outside a Windows drive prefix".to_string(),
|
||||
));
|
||||
}
|
||||
if context.strict_workspace_confinement
|
||||
&& (raw == "~" || raw.starts_with("~/") || (cfg!(windows) && raw.starts_with("~\\")))
|
||||
{
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot use home shorthand".to_string(),
|
||||
));
|
||||
}
|
||||
let home_resolved = if !context.strict_workspace_confinement {
|
||||
resolve_home_path(raw)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let check_path = home_resolved.as_deref().unwrap_or_else(|| Path::new(raw));
|
||||
if check_path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
return Err(ToolError::PathEscape {
|
||||
path: normalize_path(&context.workspace.join(check_path)),
|
||||
});
|
||||
}
|
||||
let relative = if let Some(home_path) = home_resolved.as_deref() {
|
||||
let home_str = home_path.to_str().ok_or_else(|| {
|
||||
ToolError::permission_denied("workspace path must be valid UTF-8".to_string())
|
||||
})?;
|
||||
let ws_plain = normalize_path(&context.workspace);
|
||||
let ws_str = ws_plain.to_string_lossy();
|
||||
let can_str = workspace_canonical.to_string_lossy();
|
||||
let rel = strip_workspace_root_str(&ws_str, Some(&can_str), home_str, cfg!(windows))
|
||||
.ok_or_else(|| ToolError::PathEscape {
|
||||
path: normalize_path(home_path),
|
||||
})?;
|
||||
if rel == "." {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
PathBuf::from(rel)
|
||||
}
|
||||
} else if check_path.is_absolute()
|
||||
|| check_path.has_root()
|
||||
|| (cfg!(windows) && has_windows_drive_or_unc_prefix(raw))
|
||||
{
|
||||
let ws_str = context.workspace.to_string_lossy();
|
||||
let can_str = workspace_canonical.to_string_lossy();
|
||||
let rel = strip_workspace_root_str(&ws_str, Some(&can_str), raw, cfg!(windows))
|
||||
.ok_or_else(|| ToolError::PathEscape {
|
||||
path: PathBuf::from(raw),
|
||||
})?;
|
||||
if rel == "." {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
PathBuf::from(rel)
|
||||
}
|
||||
} else {
|
||||
let mut rel = PathBuf::new();
|
||||
for component in check_path.components() {
|
||||
match component {
|
||||
Component::Normal(part) => rel.push(part),
|
||||
Component::CurDir => {}
|
||||
_ => {
|
||||
return Err(ToolError::PathEscape {
|
||||
path: PathBuf::from(raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
rel
|
||||
};
|
||||
|
||||
if relative.as_os_str().is_empty() {
|
||||
if !allow_root_dot {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path must name an entry inside the workspace".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
validate_windows_relative_segments(&relative.to_string_lossy(), cfg!(windows))
|
||||
.map_err(ToolError::permission_denied)?;
|
||||
}
|
||||
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
/// Resolve an exact `~` or `~/` path prefix to the current user's home directory.
|
||||
///
|
||||
/// Only exact `~` and `~/` (or `~\` on Windows) prefixes are resolved. Prefixes like
|
||||
|
||||
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
|
||||
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
|
||||
- **Extension workspace confinement:** plugin workspace file reads, listings, and overlay writes now verify pinned ancestors and non-linked targets at the point of use without letting session trust flags escape the workspace, reject Windows alternate data streams and 8.3 short-name aliases for protected metadata directories, and check caller deny rules for git review queries before requesting approval. (CW-8056)
|
||||
- **Delayed-message workspaces and shell stdin approvals:** `send_later` `schedule` now keeps its workspace inside the session's reachable paths instead of accepting an arbitrary directory, and `send_later` approval prompts show the requested workspace; session shell grants now match exact inputs when `stdin` (`stdin`, `input`, `data`) is supplied instead of reusing a command-only grant across different `stdin`.
|
||||
|
||||
- **Installers no longer say Computer Use comes bundled:** the macOS install script and the install guide no longer print or state that Computer Use ships with the binary or needs Node.js 20. Computer Use is released separately and stays off until you turn it on.
|
||||
|
||||
+282
-103
@@ -4,6 +4,16 @@ use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Component, Path};
|
||||
|
||||
pub struct ConfinedDirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
pub struct ConfinedDirListing {
|
||||
pub entries: Vec<ConfinedDirEntry>,
|
||||
pub total_entries: usize,
|
||||
}
|
||||
|
||||
pub fn path_is_confined(path: &Path) -> bool {
|
||||
!path.as_os_str().is_empty()
|
||||
&& path.components().all(|component| match component {
|
||||
@@ -144,6 +154,186 @@ fn invalid_path() -> io::Error {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open_confined_dir_fd(
|
||||
workspace: &Path,
|
||||
relative: Option<&Path>,
|
||||
create: bool,
|
||||
shared: bool,
|
||||
resolve_root: bool,
|
||||
) -> io::Result<File> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
if let Some(rel) = relative
|
||||
&& !path_is_confined(rel)
|
||||
{
|
||||
return Err(invalid_path());
|
||||
}
|
||||
let workspace = if resolve_root {
|
||||
workspace.canonicalize()?
|
||||
} else {
|
||||
if !workspace.is_absolute() {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
workspace.to_path_buf()
|
||||
};
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c"/".as_ptr(),
|
||||
libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c"/".as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let mut directory = unsafe { File::from_raw_fd(fd) };
|
||||
let empty = Path::new("");
|
||||
for (path, may_create) in [
|
||||
(workspace.as_path(), false),
|
||||
(relative.unwrap_or(empty), create),
|
||||
] {
|
||||
for component in path.components() {
|
||||
let Component::Normal(name) = component else {
|
||||
if component == Component::RootDir {
|
||||
continue;
|
||||
}
|
||||
return Err(invalid_path());
|
||||
};
|
||||
let name = std::ffi::CString::new(name.as_bytes())?;
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let flags = libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
|
||||
let mut fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
if fd < 0 && may_create && io::Error::last_os_error().kind() == io::ErrorKind::NotFound
|
||||
{
|
||||
let mode = if shared { 0o777 } else { 0o700 };
|
||||
if unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), mode) } != 0
|
||||
&& io::Error::last_os_error().kind() != io::ErrorKind::AlreadyExists
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
}
|
||||
if fd < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if matches!(err.raw_os_error(), Some(libc::ENOTDIR) | Some(libc::ELOOP)) {
|
||||
let mut st: libc::stat = unsafe { std::mem::zeroed() };
|
||||
if unsafe {
|
||||
libc::fstatat(
|
||||
directory.as_raw_fd(),
|
||||
name.as_ptr(),
|
||||
&mut st,
|
||||
libc::AT_SYMLINK_NOFOLLOW,
|
||||
)
|
||||
} == 0
|
||||
&& (st.st_mode & libc::S_IFMT) == libc::S_IFLNK
|
||||
{
|
||||
return Err(io::Error::from_raw_os_error(libc::ELOOP));
|
||||
}
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
directory = unsafe { File::from_raw_fd(fd) };
|
||||
}
|
||||
}
|
||||
let real_fd = unsafe {
|
||||
libc::openat(
|
||||
directory.as_raw_fd(),
|
||||
c".".as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if real_fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(unsafe { File::from_raw_fd(real_fd) })
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct DirStream(*mut libc::DIR);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for DirStream {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
libc::closedir(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn read_confined_dir(
|
||||
workspace: &Path,
|
||||
relative: Option<&Path>,
|
||||
max_entries: usize,
|
||||
) -> io::Result<ConfinedDirListing> {
|
||||
use std::os::fd::AsRawFd;
|
||||
let normalized = relative.filter(|p| !p.as_os_str().is_empty() && *p != Path::new("."));
|
||||
let dir_file = open_confined_dir_fd(workspace, normalized, false, true, true)?;
|
||||
let iter_fd = unsafe { libc::dup(dir_file.as_raw_fd()) };
|
||||
if iter_fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let raw_dir = unsafe { libc::fdopendir(iter_fd) };
|
||||
if raw_dir.is_null() {
|
||||
let error = io::Error::last_os_error();
|
||||
unsafe { libc::close(iter_fd) };
|
||||
return Err(error);
|
||||
}
|
||||
let stream = DirStream(raw_dir);
|
||||
let mut entries = Vec::new();
|
||||
let mut total_entries = 0usize;
|
||||
loop {
|
||||
let entry = unsafe { libc::readdir(stream.0) };
|
||||
if entry.is_null() {
|
||||
break;
|
||||
}
|
||||
let name_cstr = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
|
||||
let name_bytes = name_cstr.to_bytes();
|
||||
if name_bytes == b"." || name_bytes == b".." {
|
||||
continue;
|
||||
}
|
||||
total_entries += 1;
|
||||
if entries.len() >= max_entries {
|
||||
continue;
|
||||
}
|
||||
let mut stat = std::mem::MaybeUninit::<libc::stat>::zeroed();
|
||||
let st_ok = unsafe {
|
||||
libc::fstatat(
|
||||
dir_file.as_raw_fd(),
|
||||
name_cstr.as_ptr(),
|
||||
stat.as_mut_ptr(),
|
||||
libc::AT_SYMLINK_NOFOLLOW,
|
||||
)
|
||||
} == 0;
|
||||
let is_dir = if st_ok {
|
||||
let stat = unsafe { stat.assume_init() };
|
||||
(stat.st_mode & libc::S_IFMT) == libc::S_IFDIR
|
||||
} else {
|
||||
false
|
||||
};
|
||||
entries.push(ConfinedDirEntry {
|
||||
name: String::from_utf8_lossy(name_bytes).into_owned(),
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
Ok(ConfinedDirListing {
|
||||
entries,
|
||||
total_entries,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug)]
|
||||
pub struct WorkspaceFile {
|
||||
@@ -191,114 +381,18 @@ impl WorkspaceFile {
|
||||
shared: bool,
|
||||
resolve_root: bool,
|
||||
) -> io::Result<Self> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
if !path_is_confined(relative) {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
let workspace = if resolve_root {
|
||||
workspace.canonicalize()?
|
||||
} else {
|
||||
if !workspace.is_absolute() {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
workspace.to_path_buf()
|
||||
};
|
||||
// Use the established credential/artifact openat pattern, without
|
||||
// touching credentials or creating a second filesystem store.
|
||||
//
|
||||
// The anchor and every intermediate component are only pinned, never
|
||||
// read, and the final `openat` on the filename applies the real
|
||||
// access mode. On Linux, `O_PATH` pins a directory without read
|
||||
// permission, which keeps this working on Android: SELinux grants
|
||||
// apps search but not read on `/` and mount-point directories, so
|
||||
// `open("/", O_RDONLY|O_DIRECTORY)` fails with EACCES there and
|
||||
// every confined read/write was denied (#6980). O_PATH|O_DIRECTORY
|
||||
// still refuses non-directories (ENOTDIR), so the per-component type
|
||||
// check below is unchanged. Other Unixes have no O_PATH and keep the
|
||||
// readable-directory anchor.
|
||||
// SAFETY: static path and immediate ownership of a successful fd.
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c"/".as_ptr(),
|
||||
libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c"/".as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: this fd was just created and has no other owner.
|
||||
let mut directory = unsafe { File::from_raw_fd(fd) };
|
||||
let parents = relative.parent().ok_or_else(invalid_path)?;
|
||||
for (path, may_create) in [(workspace.as_path(), false), (parents, create)] {
|
||||
for component in path.components() {
|
||||
let Component::Normal(name) = component else {
|
||||
if component == Component::RootDir {
|
||||
continue;
|
||||
}
|
||||
return Err(invalid_path());
|
||||
};
|
||||
let name = std::ffi::CString::new(name.as_bytes())?;
|
||||
// Pin only: the caller opens the entry for real through
|
||||
// this directory fd; intermediate components need neither
|
||||
// read permission nor access to their contents. The flags
|
||||
// still reject non-directories on every platform.
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let flags = libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
|
||||
// SAFETY: directory pins the parent; name is one component.
|
||||
let mut fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
if fd < 0
|
||||
&& may_create
|
||||
&& io::Error::last_os_error().kind() == io::ErrorKind::NotFound
|
||||
{
|
||||
// SAFETY: directory and relative basename remain valid.
|
||||
let mode = if shared { 0o777 } else { 0o700 };
|
||||
if unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), mode) } != 0
|
||||
&& io::Error::last_os_error().kind() != io::ErrorKind::AlreadyExists
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: reject a symlink inserted after mkdirat.
|
||||
fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
}
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fd is freshly owned.
|
||||
directory = unsafe { File::from_raw_fd(fd) };
|
||||
}
|
||||
}
|
||||
// The walk above pinned every component O_PATH, and O_PATH fds cannot
|
||||
// be fsynced (EBADF) or read. Reopen the file's immediate parent with
|
||||
// real access flags through the pinned walk so `sync_all`, `renameat`
|
||||
// and every other use of `directory` keeps working. This open cannot
|
||||
// follow a link or escape: it resolves "." inside the pinned parent
|
||||
// itself, and O_NOFOLLOW is irrelevant for an exact "." entry.
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
let real_fd = unsafe {
|
||||
libc::openat(
|
||||
directory.as_raw_fd(),
|
||||
c".".as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if real_fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fd is freshly owned; the O_PATH fd it replaces is dropped.
|
||||
directory = unsafe { File::from_raw_fd(real_fd) };
|
||||
}
|
||||
let directory = open_confined_dir_fd(
|
||||
workspace,
|
||||
(!parents.as_os_str().is_empty()).then_some(parents),
|
||||
create,
|
||||
shared,
|
||||
resolve_root,
|
||||
)?;
|
||||
Ok(Self {
|
||||
directory,
|
||||
filename: std::ffi::CString::new(
|
||||
@@ -690,6 +784,49 @@ fn exclusive_rename_outcome(renamed: bool) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn read_confined_dir(
|
||||
workspace: &Path,
|
||||
relative: Option<&Path>,
|
||||
max_entries: usize,
|
||||
) -> io::Result<ConfinedDirListing> {
|
||||
let normalized = relative.filter(|p| !p.as_os_str().is_empty() && *p != Path::new("."));
|
||||
if let Some(rel) = normalized
|
||||
&& !path_is_confined(rel)
|
||||
{
|
||||
return Err(invalid_path());
|
||||
}
|
||||
let resolved = workspace.canonicalize()?;
|
||||
let root = WindowsDirectory::open(&resolved)?;
|
||||
let pinned = match normalized {
|
||||
Some(rel) => WindowsDirectory::open_inner(&resolved.join(rel), false, false)?,
|
||||
None => WindowsDirectory::open(&resolved)?,
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut total_entries = 0usize;
|
||||
for entry in std::fs::read_dir(&pinned.directory)? {
|
||||
let entry = entry?;
|
||||
total_entries += 1;
|
||||
if entries.len() >= max_entries {
|
||||
continue;
|
||||
}
|
||||
let file_type = entry.file_type()?;
|
||||
let is_reparse = file_type.is_symlink()
|
||||
|| std::fs::symlink_metadata(entry.path())
|
||||
.ok()
|
||||
.is_some_and(|meta| crate::path_identity::metadata_is_link_or_reparse(&meta));
|
||||
entries.push(ConfinedDirEntry {
|
||||
name: entry.file_name().to_string_lossy().to_string(),
|
||||
is_dir: file_type.is_dir() && !is_reparse,
|
||||
});
|
||||
}
|
||||
let _keep_root_pinned = root;
|
||||
Ok(ConfinedDirListing {
|
||||
entries,
|
||||
total_entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Windows path fence shared by Fleet publication and Native sandbox ACL
|
||||
/// admission. Every original absolute ancestor is opened without delete/write
|
||||
/// sharing and without following reparse points before path-based calls.
|
||||
@@ -1228,6 +1365,40 @@ mod unix_publication_tests {
|
||||
assert_eq!(mode(&workspace.path().join("private")), 0o700);
|
||||
assert_eq!(mode(&workspace.path().join("private/receipt.json")), 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_confined_dir_lists_entries_and_refuses_symlinks() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(workspace.path().join("sub")).unwrap();
|
||||
std::fs::write(workspace.path().join("sub/file.txt"), "hi").unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), workspace.path().join("out")).unwrap();
|
||||
std::os::unix::fs::symlink(workspace.path().join("sub"), workspace.path().join("alias"))
|
||||
.unwrap();
|
||||
|
||||
let root_listing = read_confined_dir(workspace.path(), None, 100).unwrap();
|
||||
assert_eq!(root_listing.total_entries, 3);
|
||||
let sub_entry = root_listing
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name == "sub")
|
||||
.unwrap();
|
||||
assert!(sub_entry.is_dir);
|
||||
let link_entry = root_listing
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name == "out")
|
||||
.unwrap();
|
||||
assert!(!link_entry.is_dir);
|
||||
|
||||
let sub_listing = read_confined_dir(workspace.path(), Some(Path::new("sub")), 100).unwrap();
|
||||
assert_eq!(sub_listing.entries.len(), 1);
|
||||
assert_eq!(sub_listing.entries[0].name, "file.txt");
|
||||
|
||||
assert!(read_confined_dir(workspace.path(), Some(Path::new("out")), 100).is_err());
|
||||
assert!(read_confined_dir(workspace.path(), Some(Path::new("alias")), 100).is_err());
|
||||
assert!(read_confined_dir(workspace.path(), Some(Path::new("../escape")), 100).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
@@ -1365,6 +1536,14 @@ mod windows_root_link_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
pub fn read_confined_dir(_: &Path, _: Option<&Path>, _: usize) -> io::Result<ConfinedDirListing> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Confined Fleet artifact I/O is unavailable on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
#[derive(Debug)]
|
||||
pub struct WorkspaceFile;
|
||||
|
||||
@@ -567,8 +567,8 @@
|
||||
"path_canonicalize": 2
|
||||
},
|
||||
"crates/util/src/files.rs": {
|
||||
"path_canonicalize": 5,
|
||||
"std_fs": 5,
|
||||
"path_canonicalize": 6,
|
||||
"std_fs": 7,
|
||||
"thread_sleep": 1
|
||||
},
|
||||
"crates/util/src/git_metadata.rs": {
|
||||
|
||||
Reference in new issue
Block a user