Implement /dev/null redirection and fd duplication#55
Implement /dev/null redirection and fd duplication#55AlexandreYang wants to merge 9 commits intomainfrom
Conversation
Allow output redirections exclusively to /dev/null (discarding output via io.Discard) and fd duplication between stdout/stderr (2>&1, >&2). All other output redirect targets remain blocked at validation time. Supported forms: >/dev/null, 2>/dev/null, &>/dev/null, >>/dev/null, &>>/dev/null, 2>&1, >&2. Security enforced by literal-only AST matching at validation and defense-in-depth runtime check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implementation SummaryFiles modifiedCore implementation (2 files):
Tests (2 new files + 12 scenario files + 1 updated):
Documentation (2 files):
Design decisions
|
|
I can only run on private repositories. |
AlexandreYang
left a comment
There was a problem hiding this comment.
Code Review: PR #55 — /dev/null redirection and fd duplication
Overall this is a well-structured change with strong security posture (defense-in-depth via AST validation + runtime re-check, literal-only matching, comprehensive pentest coverage). The implementation correctly preserves the restricted shell's safety model. A few findings below, classified by severity.
Summary of Findings
| Severity | Count | Description |
|---|---|---|
| P1 | 1 | Runtime defense-in-depth does not re-check source fd for DplOut |
| P2 | 1 | fd_dup_stderr_to_stdout.yaml has skip_assert_against_bash that may be unnecessary |
| P3 | 3 | Minor style/documentation nits |
P1: Runtime DplOut handler does not validate source fd (defense-in-depth gap)
File: interp/runner_redir.go, lines 146-154 and 179-189
The orig pointer defaults to &r.stdout and only handles rd.N.Value of "1" or "2". If an unsupported source fd (e.g., 3>&1) were to bypass validation, the runtime would silently treat it as 1>&1 (a no-op on stdout) rather than rejecting it. While validation in redirectTargetIsFD does catch this, the stated goal is "defense-in-depth" where runtime also re-checks. For RdrOut/AppOut/RdrAll/AppAll the runtime re-checks the target — but for DplOut the source fd is not re-checked at runtime.
Recommendation: Add a source fd check in the DplOut runtime case (or in the orig computation) that returns an error for fds other than 1 and 2.
P2: fd_dup_stderr_to_stdout.yaml — skip_assert_against_bash may be unnecessary
File: tests/scenarios/shell/redirections/devnull/fd_dup_stderr_to_stdout.yaml
This test runs cat nonexistent_file >/dev/null 2>&1 and expects empty stdout, empty stderr, exit code 1. This is standard bash behavior — bash would produce the exact same output. Per AGENTS.md, skip_assert_against_bash should only be set when behavior intentionally diverges. Unless the error message format from the builtin cat differs from GNU coreutils cat, this flag may be hiding a real divergence.
Similarly, stderr_to_devnull.yaml also has skip_assert_against_bash: true for cat nonexistent_file 2>/dev/null. In bash, this produces empty stderr and exit code 1 — matching the expected output. Worth verifying whether these can drop the skip flag.
P3: Minor nits
-
Redundant parse call in
TestRedirToFileStillBlocked(interp/redir_devnull_test.go, lines 473-476): The test parses the script withsyntax.NewParser()and then discards the result, only to haveredirRunNoAllowedparse it again. The initial parse can be removed. -
stderr_to_devnull.yamlusesnonexistent_filewhile most other devnull scenarios use deterministic commands likeecho. If the builtincaterror message ever changes, this test could break. Consider usingecho hello 2>/dev/nullwith a command that actually writes to stderr (e.g., a failing command with known stderr). Low priority since it works today. -
Documentation:
SHELL_FEATURES.mdlists>&Nas blocked for<&Nonly, but doesn't explicitly list that3>&1,>&0,>&3etc. are still blocked. The blocked section says❌ <&N — input file descriptor duplicationbut the output fd dup blocking for unsupported fds is only implicit. Minor clarity improvement possible.
tests/scenarios/shell/redirections/devnull/fd_dup_stderr_to_stdout.yaml
Outdated
Show resolved
Hide resolved
- P1: Add default error branch for unsupported fds in redir() so that unexpected fd numbers are rejected at runtime instead of silently falling through to stdout. - P2: Remove unnecessary skip_assert_against_bash flag from fd_dup_stderr_to_stdout.yaml — verified the test matches bash output. - P3: Remove dead code (unused parse) in TestRedirToFileStillBlocked. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
I can only run on private repositories. |
AlexandreYang
left a comment
There was a problem hiding this comment.
Code Review: PR #55 — Iteration 2 (post-fix verification)
Previous findings status
The 3 addressed fixes from commit 42e22d0:
| # | Previous Finding | Status |
|---|---|---|
| 1 | P1: Runtime DplOut handler does not validate source fd | Fixed — default branch added at line 153 |
| 2 | P2: fd_dup_stderr_to_stdout.yaml unnecessary skip_assert_against_bash |
Fixed — flag removed |
| 3 | P3: Dead code in TestRedirToFileStillBlocked |
Fixed — unused parse removed |
| 4 | P3: stderr_to_devnull.yaml unnecessary skip_assert_against_bash |
Not addressed |
| 5 | P3: Documentation nit in SHELL_FEATURES.md | Not addressed (acceptable) |
New findings from this iteration
Positive observations
- Defense-in-depth pattern (AST validation + runtime re-check) is well-implemented
- Strict literal-only AST matching prevents variable injection attacks
- Comprehensive pentest coverage with path traversal, quoting, glob, and fd attacks
- Correct use of
io.Discard— no files are ever opened for writing - Platform-aware
isDevNull()with case-insensitive Windows NUL support
Overall assessment: needs fixes (1 P1 regression introduced by the P1 fix from iteration 1)
tests/scenarios/shell/redirections/devnull/stderr_to_devnull.yaml
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 42e22d0d8d
ℹ️ 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".
…_bash - Handle explicit fd 0 in redirect switch so `cat 0< file` works correctly instead of hitting the "unsupported fd" error in the default branch. fd 0 is only allowed for input redirects (RdrIn); using it with output ops still returns an error. - Remove skip_assert_against_bash from stderr_to_devnull test since its output matches bash exactly. - Add test scenario for explicit fd 0 input redirection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
I can only run on private repositories. |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review-Fix Loop Summary
Iteration log
Final state
Remaining issuesNone — PR is clean and ready to merge. |
|
Codex Review: Didn't find any major issues. You're on a roll. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AlexandreYang
left a comment
There was a problem hiding this comment.
Review Summary
Reviewed the implementation of /dev/null redirection and fd duplication support. The PR adds:
- Validation-time checks allowing
>/dev/null,2>/dev/null,&>/dev/null,>>/dev/null,&>>/dev/null - Runtime execution that sets
io.Discardfor allowed redirects - Fd duplication (
2>&1,>&2) between stdout and stderr - Explicit fd 0 input redirect support (
0< file) - Defense-in-depth: runtime re-validation after variable expansion
- Comprehensive Go tests (functional + pentest) and 14 YAML scenario tests
- Updated documentation (README.md and SHELL_FEATURES.md)
Overall Assessment: Safe to merge
The security design is solid:
- Two-layer validation (AST-time + runtime) prevents bypass via variable expansion
- Only literal
/dev/nullis accepted (no variables, globs, quotes, or path variants) - Path traversal variants (
/dev/null/../..,/dev//null,/dev/./null) are correctly rejected - Unsupported fds (0, 3-9) are blocked for output redirects
DplIn(<&N),RdrInOut(<>), and herestrings (<<<) remain blocked- Windows
NULis handled case-insensitively with exact match only
Coverage Summary
| Code path | Scenario test | Go test | Status |
|---|---|---|---|
>/dev/null |
stdout_to_devnull.yaml | TestRedirStdoutToDevNull | Covered |
> /dev/null (space) |
stdout_to_devnull_space.yaml | TestRedirStdoutToDevNullWithSpace | Covered |
1>/dev/null |
explicit_fd1_devnull.yaml | TestRedirExplicitFd1ToDevNull | Covered |
2>/dev/null |
stderr_to_devnull.yaml | TestRedirStderrToDevNull | Covered |
&>/dev/null |
both_to_devnull.yaml | TestRedirBothToDevNull | Covered |
>>/dev/null |
append_devnull.yaml | TestRedirAppendToDevNull | Covered |
&>>/dev/null |
append_both_devnull.yaml | TestRedirAppendBothToDevNull | Covered |
2>&1 |
fd_dup_stderr_to_stdout.yaml | TestRedirDupStderrToStdout | Covered |
>&2 |
fd_dup_stdout_to_stderr.yaml | TestRedirDupStdoutToStderr | Covered |
0< file |
input_fd0_explicit.yaml | — | Covered |
| Exit code preservation | devnull_preserves_exit_code.yaml | TestRedirDevNullPreservesExitCode | Covered |
| Pipe + redirect combo | devnull_with_pipe.yaml | TestRedirDevNullWithPipe | Covered |
| File redirect blocked | redirect_to_file_still_blocked.yaml | TestRedirToFileStillBlocked | Covered |
| Path traversal blocked | devnull_path_traversal_blocked.yaml | TestPentestRedirPathTraversal | Covered |
| Variable target blocked | — | TestRedirVariableTargetBlocked | Covered (Go) |
| Quoted devnull blocked | — | TestPentestRedirQuotedDevNull | Covered (Go) |
>&- (close fd) blocked |
— | TestPentestRedirFdDupAttacks | Covered (Go) |
0>/dev/null blocked |
— | TestPentestRedirFdDupAttacks | Covered (Go) |
3>/dev/null blocked |
— | TestRedirFd3Blocked | Covered (Go) |
<&N blocked |
— | TestPentestRedirDplInBlocked | Covered (Go) |
<> blocked |
— | TestPentestRedirReadWriteBlocked | Covered (Go) |
| Sensitive file targets | — | TestPentestRedirToSensitiveFiles | Covered (Go) |
| No file created | — | TestPentestRedirNoFileCreated | Covered (Go) |
Overall coverage: Adequate
Positive Observations
- Excellent two-layer security model (validation + runtime defense-in-depth)
- Only literal AST nodes accepted — variables, quotes, and globs are all rejected
- Comprehensive pentest test suite covers path traversal, variable injection, quoting, globs, fd manipulation, and sensitive file targets
- Scenario tests properly use
skip_assert_against_bash: trueonly for intentionally-divergent behaviors - Clean separation of validation (validate.go) from execution (runner_redir.go)
- Documentation updated in both README.md and SHELL_FEATURES.md
|
I can only run on private repositories. |
|
Iteration 1 self-review result: COMMENT
Findings:
Overall: Safe to merge. Solid security design with two-layer validation. Proceeding to address comments. |
…scenario tests - Add comment to isDevNull explaining intentional rejection of Windows device-path prefixes and extension variants (\\.\NUL, NUL.txt) - Add scenario tests for variable redirect target rejection and >&- close-fd blocking, complementing existing Go pentest tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
I can only run on private repositories. |
AlexandreYang
left a comment
There was a problem hiding this comment.
Code Review: PR #55 — Implement /dev/null redirection and fd duplication
Review result: COMMENT
Findings Summary
| Severity | Count |
|---|---|
| P0 (Critical) | 0 |
| P1 (High) | 0 |
| P2 (Medium) | 2 |
| P3 (Low/Nit) | 2 |
P2 — Medium
1. PR scope creep: if/elif/else support, break/continue clamping, and skill changes are bundled in
This PR title is "Implement /dev/null redirection and fd duplication" but it also includes:
- Full if/elif/else clause implementation (removing the IfClause validation block, adding execution logic in runner_exec.go)
- Excess break/continue clamping at outermost loop (the contnEnclosing > 0 && !r.inLoop logic and post-loop clamping)
- Early-exit from stmts() on break/continue/exit
- 40+ new if-clause scenario tests and a pentest file (if_clause_pentest_test.go)
- .claude/skills/review-fix-loop/SKILL.md changes (PR comment posting)
It appears these came from a merge of the main branch plus commit 570cad4 ("Implement if/elif/else clause support (#45)") which was already merged to main separately. The diff against main correctly shows them because this branch was rebased/merged. However, reviewers should be aware that the if-clause and loop clamping changes are the bulk of the diff (the actual /dev/null feature is ~130 lines of Go across 2 files).
2. RdrAll/AppAll ignores the orig pointer derived from rd.N
In runner_redir.go, the code at the top determines orig based on rd.N, but the RdrAll/AppAll case (lines 179-186) directly assigns r.stdout = io.Discard and r.stderr = io.Discard without using orig. This is currently correct behavior (bash does not allow an explicit fd on &>), but it means the orig computation is dead code for those cases. A defensive comment or an early-return restructuring would make the intent clearer and prevent future confusion if someone adds fd-specific &> support.
P3 — Low/Nit
3. Redundant ClbOut handling
syntax.ClbOut (>|, clobber-output) is handled together with syntax.RdrOut in the validation and runtime. This is correct — they behave identically for /dev/null. But there is no test scenario that explicitly uses >|/dev/null. Consider adding one for completeness (it would also serve as documentation that clobber-output is intentionally supported to /dev/null).
4. Minor: isDevNull Windows behavior is asymmetric with validation
The isDevNull function accepts NUL (case-insensitive) on Windows, but the redirectTargetIsDevNull validator only accepts literal AST nodes. If someone writes > /dev/null on Windows, it passes validation (literal NUL -> isDevNull returns true) and also passes the runtime check. However, /dev/null on Windows also passes (since /dev/null is always accepted). This means on Windows both > /dev/null and > /dev/null work, while on Linux only > /dev/null works. This is documented in the isDevNull comment, so this is just a nit about the asymmetry being potentially surprising.
Positive observations
- Defense-in-depth pattern is well-executed: Validation rejects non-literal redirect targets at parse time, and the runtime re-checks after variable expansion. This two-layer approach is excellent for a security-sensitive shell.
- Pentest coverage is thorough: Path traversal, variable injection, quoting attacks, fd attacks, and sensitive file targets are all covered.
- The io.Discard approach is clean: No file is ever opened for writing — output is simply discarded in-process.
- Break/continue/exit propagation through if-clauses is correct: The stmts() early-exit and the condition-level propagation checks handle all edge cases (exit in condition, break in body, continue in elif).
- Scenario tests properly avoid skip_assert_against_bash for bash-compatible behavior, and correctly set it for intentionally divergent behaviors (blocked commands/redirects).
|
Iteration 1 self-review result: COMMENT
Summary: /dev/null redirection implementation is well-designed with defense-in-depth. Two P2 findings about PR scope (merged commits from main) and a dead code path in runner_redir.go. Two P3 nits about missing clobber test coverage and Windows behavior documentation. All tests pass. No correctness bugs found. |
Address PR review comment: add clarifying comment explaining that &>/&>> ops assign r.stdout and r.stderr directly (both fds) rather than going through the *orig pointer, since bash does not allow an explicit fd prefix on these operators. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c72d4d8974
ℹ️ 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".
|
I can only run on private repositories. |
AlexandreYang
left a comment
There was a problem hiding this comment.
Iteration 2 Self-Review
Result: COMMENT (no blocking issues)
Findings: 0 P0, 0 P1, 1 P2, 1 P3
Still-open from prior iterations
P2 (unresolved from iteration 1, chatgpt-codex-connector) — Unsupported input FDs (e.g. 3< file) are rejected at runtime but not at validation time. This weakens the "blocked features fail early with exit 2" contract. The validateRedirect function does not check rd.N for RdrIn ops, so scripts like echo hello 3< somefile will partially execute before failing. This should be caught in validation.
New findings
P3 — redirectTargetIsDevNull fd check is redundant for RdrAll/AppAll
In validate.go, redirectTargetIsDevNull checks rd.N != "1" && rd.N != "2" and returns false for unsupported fds. For RdrAll and AppAll ops, bash does not allow an explicit fd prefix, so rd.N should always be nil (which passes the check). The fd check in redirectTargetIsDevNull is harmless but misleading for these ops — it suggests fd validation matters for &> when it does not. This is cosmetic and low priority.
What looks good
- Security model is solid: The two-layer defense (validation-time literal check + runtime re-check) for
/dev/nullredirects is well-designed. Variable expansion, quoting, globs, and path traversal attacks are all correctly rejected. isDevNullis tight: Only exact/dev/null(or case-insensitiveNULon Windows) is accepted. Windows device path prefix variants are intentionally rejected.- If clause implementation is correct: Condition evaluation, branch selection, exit code propagation, break/continue interaction, and nesting all match bash behavior.
- Excess break/continue clamping at the outermost for-loop is a good fix.
- Test coverage is thorough: Scenario tests, Go unit tests, and pentest tests cover the feature well. The bash comparison tests are not skipped unnecessarily.
- Documentation is updated consistently in README.md and SHELL_FEATURES.md.
|
Iteration 2 self-review result: COMMENT
Summary: Overall implementation is solid. The remaining P2 about early validation of input FDs was carried over from iteration 1. All tests pass. |
Move the input fd check (e.g. 3< file) to validateRedirect so that unsupported input fds are caught early with exit 2, matching the shell's "blocked features fail before execution" contract. Previously these were only rejected at runtime in Runner.redir. Also clarify that the fd check in redirectTargetIsDevNull is a no-op for RdrAll/AppAll since bash does not allow explicit fd on &>/&>>. Addresses PR review comments: - P2: Input FD validation gap (unsupported fds like 3< file) - P3: Redundant fd check in redirectTargetIsDevNull for RdrAll/AppAll Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
I can only run on private repositories. |
AlexandreYang
left a comment
There was a problem hiding this comment.
Code Review: PR #55 — Iteration 3
Review result: APPROVE (posted as COMMENT due to self-review limitation)
Findings Summary
| Severity | Count |
|---|---|
| P0 (Critical) | 0 |
| P1 (High) | 0 |
| P2 (Medium) | 0 |
| P3 (Low/Nit) | 0 |
Prior findings status — all resolved
| # | Finding | Resolution |
|---|---|---|
| Iter 1 P1 | Runtime DplOut handler does not validate source fd | Fixed — default branch added in runner_redir.go:159-161 |
| Iter 1 P2 | fd_dup_stderr_to_stdout.yaml unnecessary skip_assert_against_bash |
Fixed — flag removed |
| Iter 1 P3 | Dead code in TestRedirToFileStillBlocked |
Fixed — unused parse removed |
| Iter 1 P3 | stderr_to_devnull.yaml unnecessary skip_assert_against_bash |
Fixed — flag removed |
| Iter 2 P1 | Regression: fd 0 validation rejected 0< file input redirection |
Fixed — fd 0 case added for RdrIn in runner_redir.go:149-154 |
| Iter 2 P2 | Input fd validation gap (unsupported fds like 3< file) |
Fixed in commit 75054e7 — validateRedirect now checks rd.N for RdrIn |
| Iter 2 P3 | Redundant fd check in redirectTargetIsDevNull for RdrAll/AppAll | Fixed — clarifying comment added explaining the no-op behavior |
| Iter 2 P2 | orig unused for RdrAll/AppAll |
Fixed — comment added in runner_redir.go:179-182 explaining why |
Verification
- All tests pass (
go test ./interp/... ./tests/...) - CI checks are green
- No remaining
skip_assert_against_bashon bash-compatible scenarios
Assessment
The implementation is well-structured and security-sound:
- Two-layer defense-in-depth: Validation rejects non-literal targets at AST time; runtime re-checks after variable expansion. Both layers are consistent and complete.
- Strict literal-only matching: Only bare
/dev/nullliteral AST nodes pass — variables, quotes, globs, path traversal variants all correctly rejected. - Clean fd handling: Source fd validation at both validation-time and runtime. fd 0 correctly allowed only for input redirects. fds 3+ blocked everywhere.
io.Discardapproach: No files are ever opened for writing — output is discarded in-process.- Comprehensive test coverage: 14 scenario tests (YAML), 23 Go integration tests, 16 pentest tests covering path traversal, variable injection, quoting attacks, fd attacks, and sensitive file targets.
- If-clause implementation (from merged main): Correct condition evaluation, branch selection, exit/break/continue propagation, and excess break/continue clamping.
|
Iteration 3 self-review result: APPROVE
All prior findings have been resolved. No new issues found. Implementation is clean with solid two-layer defense-in-depth, strict /dev/null matching, correct fd handling, and comprehensive test coverage. |
Review-Fix Loop Summary
Iteration log
Final state
Remaining issuesNone — PR is clean. |
|
Codex Review: Didn't find any major issues. 🚀 ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
I can only run on private repositories. |
|
Iteration 1 self-review result: APPROVE. Findings by severity: P0=0, P1=0, P2=0, P3=0. Summary: reviewed the if clause support, /dev/null redirection handling, fd duplication validation/runtime paths, and related docs/tests; targeted Go tests and the bash-comparison suite passed. |
AlexandreYang
left a comment
There was a problem hiding this comment.
Self-review iteration 1: APPROVE-equivalent outcome. GitHub does not allow approving your own pull request, so this is recorded as a comment review instead.
Reviewed the if execution path, redirect validation/runtime behavior for /dev/null and fd duplication, and the associated docs/tests. I did not find merge-blocking issues. Verification run: go test ./interp/... ./tests/... -run TestRedir|TestIf|TestShellScenariosAgainstBash -timeout 120s and RSHELL_BASH_TEST=1 go test ./tests/ -run TestShellScenariosAgainstBash -timeout 120s.
Review-Fix Loop Summary
Iteration log
Final state
Remaining issues (if any) |
|
I can only run on private repositories. |
|
No follow-up code action is needed from this trigger. The trigger indicates the review/fix loop is already CLEAN with no unresolved issues and passing CI, and the local branch is already at the latest fix commit with a clean working tree. I did not make changes, so I did not create a new commit or PR. Checks run
|
|
Codex Review: Didn't find any major issues. Can't wait for the next one! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Summary
>,>>,&>,&>>) exclusively to/dev/null, discarding output viaio.Discard— no file is ever opened for writing2>&1,>&2) between stdout and stderr onlySupported forms
>/dev/null,1>/dev/null2>/dev/null&>/dev/null>>/dev/null,&>>/dev/null2>&1>&2Security
/dev/nullis accepted — variables ($VAR), quotes ('/dev/null'), globs, and path traversal (/dev/null/../..) are all rejectedNUL(case-insensitive) on WindowsTest plan
dup_out.yamltest (now uses fd 3 since2>&1is allowed)go test ./interp/... ./tests/...)🤖 Generated with Claude Code