Skip to content
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,15 @@ quieter, and Fleet runs can be checked before they spend anything.
- `codewhale fleet run <spec> --check` runs every validation a real run would
and stops there: nothing is created, launched or spent.
- A queued agent says why it is waiting, for example when launches are
throttled after provider rate limits, and when its time budget ends
throttled after provider rate limits, and when it stops waiting
([#6277](https://github.com/Hmbown/Codewhale/issues/6277)).
- Read-only agents can run chained inspection commands (a leading `cd`,
`&&`, `;`, `echo` separators, `2>/dev/null`), and a refused command now
names the rule it broke and what to do instead. Durable Fleet workers
accept the same read-only commands as in-session agents. An agent's time
budget starts when it launches, and a queued agent that never gets a slot
says it never started
([#6015](https://github.com/Hmbown/Codewhale/issues/6015)).
- Stopping an agent that writes files keeps and names the work it had
changed, as a budget stop already did
([#5529](https://github.com/Hmbown/Codewhale/issues/5529)).
Expand Down
738 changes: 659 additions & 79 deletions crates/execpolicy/src/command_safety.rs

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,15 @@ quieter, and Fleet runs can be checked before they spend anything.
- `codewhale fleet run <spec> --check` runs every validation a real run would
and stops there: nothing is created, launched or spent.
- A queued agent says why it is waiting, for example when launches are
throttled after provider rate limits, and when its time budget ends
throttled after provider rate limits, and when it stops waiting
([#6277](https://github.com/Hmbown/Codewhale/issues/6277)).
- Read-only agents can run chained inspection commands (a leading `cd`,
`&&`, `;`, `echo` separators, `2>/dev/null`), and a refused command now
names the rule it broke and what to do instead. Durable Fleet workers
accept the same read-only commands as in-session agents. An agent's time
budget starts when it launches, and a queued agent that never gets a slot
says it never started
([#6015](https://github.com/Hmbown/Codewhale/issues/6015)).
- Stopping an agent that writes files keeps and names the work it had
changed, as a budget stop already did
([#5529](https://github.com/Hmbown/Codewhale/issues/5529)).
Expand Down
77 changes: 44 additions & 33 deletions crates/tui/src/tools/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,42 +539,53 @@ pub(crate) fn enforce_tool_authority(
}
let capabilities = tool.capabilities();
if matches!(name, "bash" | "Bash" | "exec_shell") {
// Numeric sed inspection already has an execution-time read-only
// grammar. Reuse it here without promoting the broader child shell
// surface (including pipelines/network reads) into machine authority,
// or changing the parent's parallel/approval classification (#6015).
let bounded_sed = context.shell_policy == crate::worker_profile::ShellPolicy::ReadOnly
&& input
.get("command")
.and_then(Value::as_str)
.is_some_and(|command| {
command.split_whitespace().next() == Some("sed") && !command.contains('|')
})
&& super::shell::agent_readonly_bash_input(input);
if tool.is_read_only_for(input) || bounded_sed {
if authority.shell != crate::tools::spec::ToolShellAuthority::ReadOnly {
return Err(ToolError::permission_denied(format!(
// One authority (#6015): a durable worker's shell is judged by the
// same agent read-only grammar and input normalization as in-session
// agents (`agent_readonly_bash_verdict`), and `BashTool::execute`
// applies it again under the clamped `ShellPolicy::ReadOnly`. The
// parent's parallel/approval classification (`is_read_only_for`) is
// not an authority here.
let verdict = super::shell::agent_readonly_bash_verdict(input);
if authority.shell != crate::tools::spec::ToolShellAuthority::ReadOnly {
return Err(ToolError::permission_denied(if verdict.is_ok() {
format!(
"worker '{}' cannot run {name}: its machine-readable authority envelope does not grant read-only shell access",
authority.owner
)));
}
let networked_read = input
.get("command")
.and_then(Value::as_str)
.is_some_and(codewhale_execpolicy::command_safety::is_github_readonly_command);
if networked_read && authority.network_access != Some(true) {
return Err(ToolError::permission_denied(format!(
"worker '{}' cannot use read-only GitHub CLI access: its machine-readable authority envelope does not grant network access",
authority.owner
)));
}
return Ok(());
)
} else {
format!(
"worker '{}' cannot run {name}: arbitrary command execution is outside its machine-readable authority envelope. {}",
authority.owner,
codewhale_execpolicy::command_safety::readonly_command_help()
)
}));
}
return Err(ToolError::permission_denied(format!(
"worker '{}' cannot run {name}: arbitrary command execution is outside its machine-readable authority envelope. {}",
authority.owner,
codewhale_execpolicy::command_safety::readonly_command_help()
)));
if let Err(rejection) = verdict {
return Err(ToolError::permission_denied(format!(
"worker '{}' cannot run {name}: {}",
authority.owner,
super::shell::readonly_refusal(
&rejection,
super::shell::readonly_enforced_lane_available(context)
)
)));
}
let hosts = input
.get("command")
.and_then(Value::as_str)
.map(codewhale_execpolicy::command_safety::readonly_network_reads)
.unwrap_or_default()
.into_iter()
.map(codewhale_execpolicy::command_safety::NetworkRead::host)
.collect::<Vec<_>>();
if !hosts.is_empty() && authority.network_access != Some(true) {
return Err(ToolError::permission_denied(format!(
"worker '{}' cannot use read-only network access to {}: its machine-readable authority envelope does not grant network access",
authority.owner,
hosts.join(", ")
)));
}
return Ok(());
}
if name == "Run" {
if bounded_verifier {
Expand Down
64 changes: 51 additions & 13 deletions crates/tui/src/tools/registry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,19 @@ async fn fleet_authority_allows_only_classifier_proven_readonly_bash() {
"gh issue list --limit 10",
"gh issue view 5287 --json title,state",
"sed -n '2,3p' src/evidence.txt",
// #6015: durable workers accept the same grammar as in-session
// agents — pipelines, chains, find, git -C and a leading cd.
"rg -n foo src | head -5",
"find . -name '*.rs'",
"git -C . log --oneline -3",
"cd src && git diff",
"git diff HEAD && echo '=== FILES ===' && ls -la",
"rg -n foo src 2>/dev/null",
"sed -n '2p' src/evidence.txt | head -n 1",
"sed -n '2p' src/evidence.txt | gh issue list",
"gh issue list | sed -n '2p'",
"npm view codewhale",
"find src -name '*.rs'",
] {
enforce_tool_authority(
"Bash",
Expand Down Expand Up @@ -1057,20 +1070,21 @@ async fn fleet_authority_allows_only_classifier_proven_readonly_bash() {
"sed -n '2p' $(touch src/no.txt)",
"sed -n '2p' src/evidence.txt > src/no.txt",
"sed -n '2p' src/evidence.txt && touch src/no.txt",
"sed -n '2p' src/evidence.txt | head -n 1",
"sed -n '2p' src/evidence.txt | gh issue list",
"gh issue list | sed -n '2p'",
"npm view codewhale",
"find src -name '*.rs'",
"find src -delete",
"awk '1' src/evidence.txt",
"git commit -m x",
"sort -o src/no.txt src/evidence.txt",
"cd /etc; cat passwd",
] {
let error = registry
.execute_full("Bash", json!({"action": "run", "command": command}))
.await
.expect_err("mutating Bash remains outside machine authority")
.to_string();
assert!(error.contains("arbitrary command execution"), "{error}");
// The refusal names the rule, from the same classifier every
// read-only gate uses.
assert!(error.contains("[shell.readonly.command]"), "{error}");
assert!(error.contains("File tool"), "{error}");
}
assert!(!tmp.path().join("src/no.txt").exists());

Expand Down Expand Up @@ -1196,6 +1210,27 @@ fn fleet_authority_intersects_readonly_github_bash_with_network_ceiling() {
.expect_err("network denial must win")
.to_string();
assert!(error.contains("does not grant network access"), "{error}");

// #6015: a network read cannot hide inside a pipeline or chain, and npm
// registry reads need the same grant.
for command in [
"gh pr view 1 | head",
"ls && gh issue list",
"npm view x",
"cd . && gh pr view 1",
"cd sub && npm view x",
] {
let input = json!({"action": "run", "command": command});
enforce_tool_authority("Bash", &input, &shell, &networked)
.unwrap_or_else(|error| panic!("{command}: {error}"));
let error = enforce_tool_authority("Bash", &input, &shell, &offline)
.expect_err("network denial must win inside compositions")
.to_string();
assert!(
error.contains("does not grant network access"),
"{command}: {error}"
);
}
}

#[tokio::test]
Expand Down Expand Up @@ -1753,15 +1788,18 @@ fn machine_readonly_catalog_is_exactly_the_evidence_profile() {
assert!(tools.iter().all(|tool| tool.name != "File"));
assert!(tools.iter().all(|tool| tool.name != "Bash"));
let shell = tools.iter().find(|tool| tool.name == "bash").unwrap();
assert!(shell.description.contains("cwd field"));
assert!(shell.description.contains("`cd <dir> &&`"));
assert!(shell.description.contains("git log"));
assert!(shell.description.contains("cannot change its own role"));
let bash = registry.get("bash").unwrap();
for command in [
"git branch -a",
"cd src && git status",
"git rev-parse HEAD",
] {
enforce_tool_authority(
"bash",
&json!({"command": "cd src && git status"}),
bash.as_ref(),
registry.context(),
)
.expect("a leading cd moves into the working directory (#6015)");
for command in ["git branch -a", "git rev-parse HEAD"] {
let error = enforce_tool_authority(
"bash",
&json!({"command":command}),
Expand All @@ -1771,7 +1809,7 @@ fn machine_readonly_catalog_is_exactly_the_evidence_profile() {
.unwrap_err()
.to_string();
assert!(
error.contains("cwd field") && error.contains("git log"),
error.contains("subcommand:") && error.contains("git log"),
"{error}"
);
}
Expand Down
Loading
Loading