Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/implement-posix-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ For any case where behaviour differs from expectation, run the equivalent `gtail

**GATE CHECK**: Call TaskList. Step 8 must be `completed` before starting this step. Set this step to `in_progress` now.

Update `SHELL_COMMANDS.md` in the repository root. Add a row for the new command to the reference table, following the existing format:
Update `SHELL_FEATURES.md` in the repository root. Add a row for the new command to the reference table, following the existing format:

```
| `$ARGUMENTS [FILE ...]` | `-x X` (desc), `-y` (desc) | One-sentence description of what the command does. |
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ The shell is supported on Linux, Windows and macOS.
```
RSHELL_BASH_TEST=1 go test ./tests/ -run TestShellScenariosAgainstBash -timeout 120s
```
The test suite runs all scenarios against `debian:bookworm-slim` (GNU bash + GNU coreutils) and compares output byte-for-byte. Only set `skip_assert_against_bash: true` in a scenario when the behavior intentionally diverges from bash (e.g. sandbox restrictions, blocked commands).
The test suite runs all scenarios against `debian:bookworm-slim` (GNU bash + GNU coreutils) and compares output byte-for-byte.
Only set `skip_assert_against_bash: true` in a scenario when the behavior intentionally diverges from bash (e.g. sandbox restrictions, blocked commands).
Consider making the rshell implementation compatible with bash before setting `skip_assert_against_bash: true`.

- In test scenarios, use `expect.stderr` when possible instead of `stderr_contains`.
- Test scenarios are asserted against bash by default. Only set `skip_assert_against_bash: true` for features that intentionally diverge from standard bash behavior (e.g. blocked commands, restricted redirects, readonly enforcement).
- When expected output differs on Windows (e.g. path separators `\` vs `/`), use Windows-specific assertion fields:
- `stdout_windows` / `stderr_windows` — override `stdout` / `stderr` on Windows.
- `stdout_contains_windows` / `stderr_contains_windows` — override `stdout_contains` / `stderr_contains` on Windows.
- `exit_code_windows` — override `exit_code` on Windows.
- If the Windows field is not set, the non-Windows field is used as fallback.
14 changes: 0 additions & 14 deletions SHELL_COMMANDS.md

This file was deleted.

20 changes: 13 additions & 7 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ Blocked features are rejected before execution with exit code 2.

## Builtins

- ✅ `echo` — prints arguments separated by spaces, followed by a newline
- ✅ `cat` — reads files or stdin (`-`); respects AllowedPaths
- ✅ `true` — exits with code 0
- ✅ `false` — exits with code 1
- ✅ `exit [N]` — exits with code N (default: last exit code)
- ✅ `break [N]` / `continue [N]` — loop control
- ❌ All other commands — return exit code 127 with `<cmd>: not found` unless an ExecHandler is configured
| Command | Options | Short description |
| --- | --- | --- |
| `true` | none | Exit with status `0`. |
| `false` | none | Exit with status `1`. |
| `echo [ARG ...]` | none | Print arguments separated by spaces, then newline. |
| `cat [FILE ...]` | `-` (read stdin) | Print files; with no args, read stdin. |
| `head [FILE ...]` | `-n N` (lines), `-c N` (bytes), `-q`/`--quiet`/`--silent` (no headers), `-v` (force headers) | Print first 10 lines of each FILE; with no FILE or `-`, read stdin. |
| `test EXPR` / `[ EXPR ]` | `-e`/`-f`/`-d`/`-s`/`-r`/`-w`/`-x`/`-L` (file tests), `-n`/`-z`/`=`/`!=` (strings), `-eq`/`-ne`/`-lt`/`-gt`/`-le`/`-ge` (integers), `-nt`/`-ot`/`-ef` (file comparison), `!`/`-a`/`-o` (logic) | Evaluate conditional expression; exit 0 (true) or 1 (false). |
| `exit [N]` | `N` (status code) | Exit the shell with `N` (default: last status). |
| `break [N]` | `N` (loop levels) | Break current loop, or `N` enclosing loops. |
| `continue [N]` | `N` (loop levels) | Continue current loop, or `N` enclosing loops. |

All other commands return exit code 127 with `<cmd>: not found` unless an ExecHandler is configured.

## Variables

Expand Down
34 changes: 34 additions & 0 deletions interp/allowed_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,40 @@ func (s *pathSandbox) readDir(ctx context.Context, path string) ([]fs.DirEntry,
return entries, nil
}

// stat returns a FileInfo for the given path. The path is resolved through the
// sandbox the same way as open().
func (s *pathSandbox) stat(ctx context.Context, path string) (os.FileInfo, error) {
absPath := toAbs(path, HandlerCtx(ctx).Dir)

root, relPath, ok := s.resolve(absPath)
if !ok {
return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission}
}

fi, err := root.Stat(relPath)
if err != nil {
return nil, portablePathError(err)
}
return fi, nil
}

// lstat returns a FileInfo for the given path without following symlinks.
// The path is resolved through the sandbox the same way as open().
func (s *pathSandbox) lstat(ctx context.Context, path string) (os.FileInfo, error) {
absPath := toAbs(path, HandlerCtx(ctx).Dir)

root, relPath, ok := s.resolve(absPath)
if !ok {
return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission}
}

fi, err := root.Lstat(relPath)
if err != nil {
return nil, portablePathError(err)
}
return fi, nil
}

// Close releases all os.Root file descriptors. It is safe to call multiple times.
func (s *pathSandbox) Close() error {
if s == nil {
Expand Down
6 changes: 6 additions & 0 deletions interp/builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ type CallContext struct {
// OpenFile opens a file within the shell's path restrictions.
OpenFile func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error)

// StatFile returns file info within the shell's path restrictions.
StatFile func(ctx context.Context, path string) (os.FileInfo, error)

// LstatFile returns file info without following symlinks.
LstatFile func(ctx context.Context, path string) (os.FileInfo, error)

// PortableErr normalizes an OS error to a POSIX-style message.
PortableErr func(err error) string
}
Expand Down
15 changes: 15 additions & 0 deletions interp/builtins/test/executable_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2026-present Datadog, Inc.

//go:build !windows

package test

import "os"

// isExecutable reports whether the file described by fi has the execute permission bit set.
func isExecutable(fi os.FileInfo) bool {
return fi.Mode().Perm()&0111 != 0
}
32 changes: 32 additions & 0 deletions interp/builtins/test/executable_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2026-present Datadog, Inc.

//go:build windows

package test

import (
"os"
"path/filepath"
"strings"
)

// windowsExeExts lists extensions that Windows considers executable.
var windowsExeExts = map[string]bool{
".exe": true, ".cmd": true, ".bat": true, ".com": true,
}

// isExecutable reports whether the file described by fi is considered executable on Windows.
// Windows does not have Unix permission bits; instead, executability is determined by file extension.
// If the file has no recognizable executable extension, it is conservatively treated as executable
// (matching the behavior of test -x on most Windows environments).
func isExecutable(fi os.FileInfo) bool {
ext := strings.ToLower(filepath.Ext(fi.Name()))
if ext == "" {
// No extension — treat as executable (consistent with Unix behavior for chmod +x files).
return true
}
return windowsExeExts[ext]
}
Loading