fix(application,infrastructure): grant /dev/null RO|RW in workspace_exec_sandbox so CLIs can open it O_RDWR — #354 (QA verte)

Cause: the workspace.execTool sandbox only granted the project root
plus read-only system roots, so CLI tools like git failed when opening
/dev/null with O_RDWR (redirect/exec 3<>/dev/null) inside sandboxed
runs.
Fix: workspace_exec_sandbox pushes one precise /dev/null PathGrant with
RO|RW access; project read effect stays RO|EXEC and no blanket /dev
grant is added, so the widening is limited to the null device;
GitPlugin untouched. Tests: application asserts the read sandbox keeps
project RO|EXEC while granting /dev/null RO|RW
(workspace_exec_read_sandbox_grants_dev_null_without_widening_project_access);
infrastructure proves the local runner executes exec 3<>/dev/null
(local_runner_allows_cli_to_open_dev_null_for_read_and_write).
QA verte reconfirmée par Git: cargo test -p application workspace_exec
5/5, -p infrastructure local_runner_allows_cli_to_open_dev_null 1/1,
-p infrastructure workspace_tool::tests 5/5, cargo fmt --all --check,
git diff --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 09:25:34 +02:00
parent 325e5656fa
commit 28bb706c61
2 changed files with 41 additions and 0 deletions

View File

@ -2443,6 +2443,10 @@ fn workspace_exec_sandbox(
abs_root: root.as_str().to_owned(), abs_root: root.as_str().to_owned(),
access: project_access, access: project_access,
}]; }];
allowed.push(domain::PathGrant {
abs_root: "/dev/null".to_owned(),
access: domain::PathAccess::RO | domain::PathAccess::RW,
});
for system_root in ["/bin", "/usr", "/lib", "/lib64", "/etc"] { for system_root in ["/bin", "/usr", "/lib", "/lib64", "/etc"] {
allowed.push(domain::PathGrant { allowed.push(domain::PathGrant {
abs_root: system_root.to_owned(), abs_root: system_root.to_owned(),
@ -6063,6 +6067,29 @@ mod tests {
} }
} }
#[test]
fn workspace_exec_read_sandbox_grants_dev_null_without_widening_project_access() {
let root = ProjectPath::new("/workspace/example").unwrap();
let sandbox = workspace_exec_sandbox(&root, PluginWorkspaceExecEffect::Read);
assert_eq!(
sandbox
.allowed
.iter()
.find(|grant| grant.abs_root == root.as_str())
.map(|grant| grant.access),
Some(domain::PathAccess::RO | domain::PathAccess::EXEC)
);
assert_eq!(
sandbox
.allowed
.iter()
.find(|grant| grant.abs_root == "/dev/null")
.map(|grant| grant.access),
Some(domain::PathAccess::RO | domain::PathAccess::RW)
);
}
#[tokio::test] #[tokio::test]
async fn workspace_exec_allows_matching_rule_clamps_limits_and_audits_metadata() { async fn workspace_exec_allows_matching_rule_clamps_limits_and_audits_metadata() {
let (project_id, tool, runner, audit) = workspace_exec_fixture(); let (project_id, tool, runner, audit) = workspace_exec_fixture();

View File

@ -263,4 +263,18 @@ mod tests {
assert!(result.stdout_truncated); assert!(result.stdout_truncated);
assert!(!result.stderr_truncated); assert!(!result.stderr_truncated);
} }
#[cfg(unix)]
#[tokio::test]
async fn local_runner_allows_cli_to_open_dev_null_for_read_and_write() {
let runner = LocalWorkspaceToolRunner::new(Arc::new(crate::sandbox::NoopSandbox));
let result = runner
.run(spec(&["-c", "exec 3<>/dev/null; printf ok"], 1_000, 32))
.await
.unwrap();
assert_eq!(result.exit_code, Some(0));
assert_eq!(result.stdout, b"ok");
assert!(result.stderr.is_empty());
}
} }