Skip to content

feat(fleet): usable read-only shell for scouts/reviewers (#5426) - #5428

Merged
Hmbown merged 3 commits into
mainfrom
codex/v099-scout-readonly-bash
Aug 16, 2026
Merged

feat(fleet): usable read-only shell for scouts/reviewers (#5426)#5428
Hmbown merged 3 commits into
mainfrom
codex/v099-scout-readonly-bash

Conversation

@Hmbown

@Hmbown Hmbown commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Fixes #5426. Splits the read-only shell classifier so ShellPolicy::ReadOnly agents keep the #5356 bounded shell usable, without touching the parent's parallel auto-approve posture.

  • New is_agent_readonly_shell_command: pipelines (every segment validated), globs, git -C/--no-pager, find (mutating primaries denied), sed -n <range>p, npm view|show|info, sort/uniq/cut/tr/comm.
  • head/tail -N recognized as -n N (digit-only, line-count commands only) — shared, still read-only.
  • Single commands exec direct-argv as before (-C-aware splice); pipelines exec via set -o pipefail over pre-validated segments; workspace operand confinement unchanged.
  • Gates: command_safety 64/64, shell 113/113, fleet exact 42/42, readonly-tagged 34/34.

Merge AFTER the v0.9.8 publish completes (release lane frozen at tag).

CodeWhale Bot added 2 commits August 16, 2026 10:01
…5356 follow-up)

Parked while 0.9.8 publishes. Split classifier + shell gate + git -C/pipeline
exec paths are in; test run pending. Do not merge as-is.
ShellPolicy::ReadOnly gated every concrete bash call through
is_parallel_readonly_command — the classifier built for the parent's
parallel auto-approve chunks, whose tightness (no pipes, no globs, no
git -C, no find) is load-bearing there but left the #5356 bounded shell
behaviorally dead: live scouts concluded 'bash is entirely denied' and
crawled with read/grep or spawned worker children just to run git.

Split the classifiers. is_agent_readonly_shell_command keeps the
mutation-proof charset (no separators, redirects, expansions,
subshells, env prefixes) but admits pipelines where every segment must
itself pass the shared token logic, git -C/--no-pager preambles that
re-enter the existing per-subcommand option tables, find without
mutating primaries, sed -n <range>p, npm view|show|info, and the pure
filters sort/uniq/cut/tr/comm. head/tail -N numeric shorthands are
recognized as -n N (digit-only form, line-count commands only).
Execution: single commands keep the direct-argv hardening (now
-C-aware); pipelines exec through set -o pipefail with every segment
pre-validated; workspace operand confinement still applies to the whole
string. The parent's parallel auto-approve path is unchanged.

Tests: 4 new classifier tables (admissions incl. CJK-adjacent pipeline
shapes; injection/mutation denials incl. find -exec, sed w-scripts,
npm install, env prefixes, empty segments; per-segment pipeline
denials; parallel-classifier non-regression), plus the existing 60
command_safety, 113 shell, 42 fleet-exact, and 34 readonly-tagged
suites green.
@cursor

cursor Bot commented Aug 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 6m 12s —— View job


Review

  • Gather context (diff, changed files, AGENTS.md rules)
  • Review crates/tui/src/command_safety.rs changes
  • Review crates/tui/src/tools/shell.rs changes
  • Check correctness, provider/model safety, reuse, tests, security
  • Post final review

Careful, well-documented work on the classifier itself (the segment-based pipeline validation, git/sed/find/npm per-command handling, and the shared readonly_tokens_admitted extraction are all clean). But two real gaps let a ShellPolicy::ReadOnly scout mutate a file or escape the workspace despite being accepted by the new classifier, and one advertised feature silently doesn't work. Provider/model-routing invariant (EPIC #2608) isn't touched by this diff.

Correctness / Security bugs

1. Pipeline + pre-existing gh short-circuit = arbitrary file read outside the workspace
crates/tui/src/tools/shell.rs:3226 (enforce_readonly_workspace_operands)

if argv.first().is_some_and(|program| program == "gh") {
    return Ok(());
}

This short-circuit was safe before this PR because is_parallel_readonly_command's charset gate rejected |, so a gh-led command was always a single, self-contained invocation. This PR's is_agent_readonly_shell_command now admits pipelines and validates each segment independently via the same readonly_tokens_admitted used for gh. So:

gh pr view 123 | cat ../../../etc/passwd

passes is_agent_readonly_shell_command (both segments individually classify read-only), then enforce_readonly_workspace_operands receives the whole piped string — argv.first() == "gh" — and returns Ok(()) without ever inspecting ../../../etc/passwd. It then runs through a real shell (command.contains('|') path, crates/tui/src/tools/shell.rs:1684), and the scout receives the contents of any file the OS process can read. Same shape works with sed, find, grep, etc. chained after any readonly gh call — this defeats the workspace confinement that is the entire point of ShellPolicy::ReadOnly.

Fix direction: split on | the same way the classifier does and validate operands per-segment, rather than trusting argv.first() of the full piped string.

2. sed -n <range>p -i <file> classifies read-only but performs an in-place mutation
crates/tui/src/command_safety.rs:632-650 (is_agent_readonly_sed)

fn is_agent_readonly_sed(tokens: &[String]) -> bool {
    if tokens.len() < 3 || tokens[1] != "-n" { return false; }
    let script = tokens[2].as_str();
    ...
}

Only tokens[1] and tokens[2] are ever inspected — anything at index 3+ is silently ignored. GNU sed's option parser permutes arguments, so -i (in-place edit) is honored even after the script token. sed -n 10p -i file.txt classifies as read-only (script "10p" is a valid numeric range) and nothing downstream catches it — hardened_readonly_argv (shell.rs:3155) only special-cases git, and enforce_readonly_workspace_operands doesn't reject a bare -i. Net effect: a ReadOnly scout can truncate/rewrite a workspace file to just the matched line, directly through a policy whose entire contract is "non-mutating."

Fix direction: require exactly 3 tokens (or reject any token after index 2 that starts with -).

3. Advertised glob (*) support never expands for single (non-pipeline) commands
crates/tui/src/tools/shell.rs:1681-1706
The new docstring says glob args are "expanded by the shell," and the test suite asserts "ls -la *.md" is admitted. But the non-pipeline path builds a CommandSpec::program(...), i.e. a direct execve with no shell involved (crates/tui/src/sandbox/mod.rs:153), so *.md is passed to ls as a literal string and is never expanded — it'll almost always match nothing. Only the pipeline path (command.contains('|'), using CommandSpec::shell) actually invokes a real shell. So ls -la *.md (no pipe) silently fails to do what the docstring/tests imply, while ls -la *.md | head works. The added tests only exercise the classifier function, not this execution-path gap.

Reuse / design (good choices)

  • readonly_tokens_admitted extraction cleanly shares the option tables between the parallel and agent classifiers instead of duplicating them.
  • is_agent_readonly_git's "strip the -C/--no-pager preamble, re-enter the shared table" approach avoids re-implementing the per-subcommand allowlists.
  • is_numeric_count_shorthand is scoped tightly (digit-only, and only for flags whose value-list includes -n/--lines), so it can't smuggle a path/value injection.

Tests

Security

Findings #1 and #2 above are the security-relevant ones — both are workspace-confinement/mutation-policy bypasses for the ReadOnly fleet posture, not classifier edge cases. I'd treat those as blocking before merge, independent of the "merge after v0.9.8 publish" note in the PR description.

Fix these →
· Branch: codex/v099-scout-readonly-bash

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 36ec61342e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"find" => is_agent_readonly_find(&tokens),
"sed" => is_agent_readonly_sed(&tokens),
"npm" => is_agent_readonly_npm(&tokens),
"sort" | "uniq" | "cut" | "tr" | "comm" => true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict write-capable text-filter options

When a read-only Scout or Reviewer inherits Agent mode's workspace-write sandbox, unconditional acceptance allows sort input -o input to rewrite a workspace file. GNU sort --help documents -o, --output=FILE as writing the result to a file, and uniq likewise accepts an output-file operand, so these commands need command-specific option and arity allowlists rather than blanket admission.

Useful? React with 👍 / 👎.

Comment on lines +633 to +634
if tokens.len() < 3 || tokens[1] != "-n" {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject trailing sed options that enable in-place writes

In the same workspace-write context, sed -n 1p -i file passes this predicate because only tokens[1] and tokens[2] are validated and all later tokens are ignored. GNU sed --help identifies -i as the in-place option; this example rewrites the file to its first line despite ShellPolicy::ReadOnly, so the remaining tokens must be parsed as operands while rejecting every additional option.

Useful? React with 👍 / 👎.

Comment on lines +1690 to +1691
let piped = format!("set -o pipefail; {command}");
CommandSpec::shell(&piped, work_dir.clone(), Duration::from_millis(timeout_ms))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply Git hardening to every pipeline segment

For any admitted pipeline, this raw-shell branch skips hardened_readonly_argv, so a command such as git show HEAD | head runs without the injected --no-textconv and --no-ext-diff flags. Git's configuration catalog includes diff.<driver>.textconv, diff.<driver>.command, and diff.external; a repository-local diff driver selected by .gitattributes can therefore execute an arbitrary helper in the supposedly read-only agent context. Build the pipeline from individually resolved and hardened argv segments instead of interpolating the original command.

Useful? React with 👍 / 👎.

Comment on lines +590 to +594
loop {
match rest.first().map(String::as_str) {
Some("--no-pager") => rest = &rest[1..],
Some("-C") if rest.len() >= 2 => rest = &rest[2..],
_ => break,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve repeated Git -C paths cumulatively

When the workspace contains sub/link as a symlink to an external directory but no top-level link, git -C sub -C link status is admitted here. The operand guard checks both sub and link against the original effective cwd, while Git resolves the second relative -C from sub, so the nonexistent top-level operand evades canonicalization and Git reads outside the workspace. Reject repeated -C options or validate them in the same cumulative order Git applies them.

Useful? React with 👍 / 👎.

Comment on lines +550 to +554
// A pipeline is admitted only when every segment is: `a | b` is two
// read-only commands, while `a | | b`, `a |`, and `||` all carry an empty
// segment and reject. Quoted pipes inside an argument mis-split here,
// which only ever makes a segment fail classification (fail closed).
trimmed.split('|').all(is_agent_readonly_segment)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce the GitHub network policy for pipeline segments

When the configured network policy denies GitHub, gh pr view 123 | head is nevertheless admitted by this per-segment classifier. enforce_readonly_github_network_policy asks is_github_readonly_command about the complete string, but that predicate delegates to the parallel classifier, which rejects every |; the guard therefore returns without evaluating api.github.com, and the Agent-mode sandbox still permits network access. Detect admitted gh segments individually before dispatch.

Useful? React with 👍 / 👎.

Comment on lines +626 to +629
tokens
.iter()
.skip(1)
.all(|token| !MUTATING_PRIMARIES.contains(&token.as_str()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject find modes that follow external symlinks

When the workspace contains a directory symlink to an external location, find -L . -type f and the equivalent -follow form pass this denylist and traverse that external tree. GNU find --help exposes both -L and -follow, and the operand guard only canonicalizes the starting . rather than paths encountered during traversal, so this bypasses the stated workspace-read boundary. Reject symlink-following modes or constrain every traversed result.

Useful? React with 👍 / 👎.

Comment on lines +1690 to +1691
let piped = format!("set -o pipefail; {command}");
CommandSpec::shell(&piped, work_dir.clone(), Duration::from_millis(timeout_ms))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run pipelines with a shell that supports pipefail

On Unix systems where $SHELL is absent and the dispatcher falls back to /bin/sh (commonly dash), every newly admitted pipeline fails before executing because /bin/sh reports set: Illegal option -o pipefail; the same prefix is also not valid for the dispatcher’s cmd.exe and PowerShell paths. Select syntax by ShellKind or execute a fixed POSIX-compatible pipeline implementation instead of injecting a Bash-only preamble into the generic shell dispatcher.

Useful? React with 👍 / 👎.

Comment on lines 1683 to +1684
let spec = if let Some(workspace) = readonly_workspace {
let (program, args) = hardened_readonly_argv(command)?;
let program = resolve_readonly_program(&program, workspace)?;
CommandSpec::program(
program
.to_str()
.ok_or_else(|| anyhow!("read-only executable path is not valid UTF-8"))?,
args,
work_dir.clone(),
Duration::from_millis(timeout_ms),
)
if command.contains('|') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expand globs for non-pipeline read-only commands

A command such as the newly tested ls -la *.md contains no pipe, so it takes the direct-program branch and receives *.md as a literal argv element; no shell performs the advertised glob expansion, and ls fails unless a file is literally named *.md. Handle validated glob expansion in the direct execution path as well as in pipelines.

Useful? React with 👍 / 👎.

Comment on lines +550 to +554
// A pipeline is admitted only when every segment is: `a | b` is two
// read-only commands, while `a | | b`, `a |`, and `||` all carry an empty
// segment and reject. Quoted pipes inside an argument mis-split here,
// which only ever makes a segment fail classification (fail closed).
trimmed.split('|').all(is_agent_readonly_segment)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate operands after a leading gh pipeline segment

For an admitted command such as gh pr view 123 | cat /etc/passwd, the workspace operand guard returns immediately merely because the first argv token is gh, so it never examines the absolute operand belonging to the later cat segment. The raw pipeline then reads that file under Agent mode, whose workspace-write sandbox does not restrict reads outside the workspace. Apply the gh exemption only to that segment and validate every remaining segment independently.

Useful? React with 👍 / 👎.

Comment on lines +652 to +656
fn is_agent_readonly_npm(tokens: &[String]) -> bool {
matches!(
tokens.get(1).map(String::as_str),
Some("view" | "show" | "info")
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply network policy to npm registry reads

A read-only agent can now run npm view, but the only shell network guard recognizes GitHub CLI commands, so npm traffic never reaches NetworkPolicyDecider. npm also honors a workspace .npmrc registry setting, allowing an untrusted repository to redirect this command to an arbitrary denied or prompt-only host while the Agent-mode sandbox permits network access. Pin and evaluate the effective registry host before admitting these npm reads.

Useful? React with 👍 / 👎.

The classifier tests landed above the original use super::*, leaving a
second, unused copy that -Dwarnings turns fatal in CI's lib-test build.
@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 2m 43s —— View job


Review

  • Gather context (diff, changed files, AGENTS.md rules)
  • Review crates/tui/src/command_safety.rs changes
  • Review crates/tui/src/tools/shell.rs changes
  • Check correctness, provider/model safety, reuse, tests, security
  • Post final review

This PR is still 36ec613 (only c966ee7, the duplicate use super::*; lint fix, was added since the last review) — the security-relevant gaps flagged earlier and by the Codex bot are still present. I re-verified each against the current code rather than re-quoting; findings below are what I confirmed by reading, plus one I hadn't seen called out yet.

Correctness / security (blocking)

1. gh ... | ... pipelines skip workspace-operand confinement entirely
crates/tui/src/tools/shell.rs:3221-3230 (enforce_readonly_workspace_operands)
shell_words::split doesn't treat | as a separator, so for gh pr view 123 | cat ../../../etc/passwd the whole line becomes one argv and argv.first() == "gh" short-circuits with Ok(()) before the cat operand is ever inspected. The pipeline then runs through a real shell (shell.rs:1684, command.contains('|')), so the scout gets the file back. Needs per-|-segment validation, not argv.first() of the full string.

2. sed -n <range>p -i file still classifies read-only and mutates
crates/tui/src/command_safety.rs:632-650 (is_agent_readonly_sed)
Only tokens[1]/tokens[2] are checked; anything from index 3 on (e.g. trailing -i) is ignored, and GNU sed's permuting option parser still honors it. sed -n 10p -i file.txt passes the classifier and truncates the file in place under a policy whose whole contract is non-mutating. Require exactly 3 tokens, or reject any token after index 2 that starts with -.

3. Pipelines skip Git diff/textconv hardening
crates/tui/src/tools/shell.rs:1684-1691 vs. hardened_readonly_argv (shell.rs:3155)
The non-pipeline path splices --no-textconv --no-ext-diff (and pager/signature flags) into classifier-approved git invocations before exec. The new pipeline branch (command.contains('|')) instead does format!("set -o pipefail; {command}") and runs it verbatim through CommandSpec::shell — none of that hardening applies. git show HEAD | head runs without --no-textconv, so a repo-local diff.<driver>.textconv/.gitattributes combo can execute an arbitrary helper inside the "read-only" agent's process. Segments need to be individually resolved/hardened before being joined into the pipeline, not string-interpolated.

4. Network policy is never evaluated for pipelines (and never exists for npm at all)
crates/tui/src/tools/shell.rs:3058-3081 (enforce_readonly_github_network_policy)
This calls is_github_readonly_command(command) on the whole command string; that predicate delegates to is_parallel_readonly_command, which rejects any | at the charset gate — so gh pr view 123 | head returns false and the network guard silently no-ops, even though the gh segment was independently admitted by is_agent_readonly_shell_command. Separately, this is the only network-policy call site in the read-only path, and it's GitHub-specific — the new npm view/show/info admission (command_safety.rs:652) has no equivalent gate at all, piped or not. A workspace .npmrc can point the registry at an arbitrary host and a ReadOnly scout will hit it regardless of NetworkPolicyDecider.

5. Advertised glob support doesn't work for non-pipeline commands, and where it does work it bypasses operand confinement
crates/tui/src/tools/shell.rs:1683-1706
The non-pipeline branch execs directly (CommandSpec::program, no shell), so *.md reaches ls as a literal argv string — ls -la *.md (the exact case asserted in the new test) matches nothing in practice. The pipeline branch does use a real shell, so *.md/*.txt does expand there — but enforce_readonly_workspace_operands validates the literal, unexpanded pattern against the workspace boundary, then the shell expands it independently. A workspace symlink like secret.txt -> /etc/passwd isn't examined by the guard (the literal glob token doesn't match it) but is a legitimate expansion result that cat will follow.

Reuse (good)

readonly_tokens_admitted extraction and is_agent_readonly_git's "strip -C/--no-pager, re-enter the shared table" approach are clean — no allowlist duplication between the parallel and agent classifiers.

Tests

Solid coverage of is_agent_readonly_shell_command itself, but nothing exercises enforce_readonly_workspace_operands, enforce_readonly_github_network_policy, or the actual CommandSpec construction in shell.rs — which is where all five issues above live. An integration-style test through BashTool::call for gh ... | cat ../../etc/passwd, sed -n 10p -i file, and git show HEAD | head (asserting the hardening flags land) would catch these directly.

I did not run cargo test/cargo fmt --check in this pass (no cached target/, and a from-scratch workspace build wasn't practical in this session) — the analysis above is from static reading, not test execution. The PR description's "64/64 / 113/113 / 42/42 / 34/34" gate numbers are unverified by me.

Fix these →
· Branch: codex/v099-scout-readonly-bash

@Hmbown
Hmbown merged commit 4b40ecb into main Aug 16, 2026
26 checks passed
@Hmbown
Hmbown deleted the codex/v099-scout-readonly-bash branch August 16, 2026 19:32
pull Bot pushed a commit to TheTechOddBug/DeepSeek-TUI that referenced this pull request Aug 16, 2026
… never widens authority

Acceptance 2 of Hmbown#5426, made mechanical and written down:

- docs/SUBAGENTS.md: delegation moves work, never authority. A read-only
  role delegating to a write-capable role is an intended escape hatch for
  work capacity, but the child clamps against the delegating parent's live
  posture, not the operator's — read-only is transitive through any
  delegation chain, and a delegated child cannot obtain canonical Bash, so
  spawning for shell is mechanically useless. The scout's own bounded
  read-only shell (PR Hmbown#5428) is the only shell path a read-only parent has.
- fleet/exact.rs: role-isolation test
  a_read_only_parents_delegation_never_widens_authority pins the end state:
  a write-capable builder member dispatched from a read-only scout lands
  write=false, shell=ReadOnly, posture_role=scout, raw shell + Bash +
  mutating tools denied, while the delegation budget (max_depth) survives —
  the escape hatch stays open for capacity, never for authority.
Hmbown added a commit that referenced this pull request Aug 17, 2026
…re-gate

fix(fleet): the scout posture gate must honor #5428's read-only shell (#5426)
timothybrush pushed a commit to timothybrush/DeepSeek-TUI that referenced this pull request Aug 17, 2026
… shell (Hmbown#5426)

The first live dogfood against a freshly built Hmbown#5428 binary denied a scout
all three canonical inspection commands (git -C … log, find … | head,
npm view) at posture_permits_tool: the Required branch demanded
ShellPolicy::Full, and the relaxed is_agent_readonly_shell_command
classifier was unreachable from the gate — it only guards the
BashTool::execute ReadOnly branch, which the gate precedes. The catalog
admitted canonical bash; the gate then refused to dispatch it.

Admit the bounded carve-out at the gate: allows_bounded_readonly_bash(name)
plus the same agent_readonly_bash_input predicate execute enforces, so the
gate can never widen past the execute-time refusal. Legacy `Bash` stays
raw-shell-denied (exact-name match), mutating commands still refuse, and
Planner keeps the same bounded admission.

Tests: scout_posture_gate_admits_agent_readonly_bash_commands pins the
gate↔classifier agreement with the exact dogfood command shapes and the
negative controls (touch denied, legacy Bash denied). Suites green:
subagent 524, shell 108, command_safety 64.
@Hmbown Hmbown mentioned this pull request Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.9.9: give scouts/reviewers a usable read-only shell (split the classifier)

1 participant