Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
334d0c8
Implement POSIX test and [ builtin commands
datadog-prod-us1-5[bot] Mar 10, 2026
5d43d5e
Merge branch 'main' into dd/implement-test-bracket-builtins
datadog-prod-us1-3[bot] Mar 10, 2026
8e590d1
Merge branch 'main' into dd/implement-test-bracket-builtins
datadog-prod-us1-5[bot] Mar 10, 2026
271c7d0
Fix test builtin: match bash behavior for -o unary, error formats, an…
AlexandreYang Mar 10, 2026
f2565a0
Fix test builtin: handle lone '(' as string and reject empty file ope…
AlexandreYang Mar 10, 2026
93878bd
Use real access checks for test -r/-w/-x via sandbox AccessFile callback
AlexandreYang Mar 10, 2026
bfdd72b
Fix access checks to go through os.Root sandbox instead of raw syscal…
AlexandreYang Mar 10, 2026
2c880c8
Merge branch 'main' into dd/implement-test-bracket-builtins
AlexandreYang Mar 10, 2026
215a289
Merge branch 'main' into dd/implement-test-bracket-builtins
AlexandreYang Mar 10, 2026
4fe2162
Fix !/( literal parsing in binary expressions and add parser depth limit
AlexandreYang Mar 10, 2026
89e3be1
Merge branch 'main' into dd/implement-test-bracket-builtins
AlexandreYang Mar 10, 2026
2b69741
Address review comments: effective access checks and pentest coverage
AlexandreYang Mar 10, 2026
54f7d49
Merge branch 'main' into dd/implement-test-bracket-builtins
AlexandreYang Mar 10, 2026
be99dc1
Merge branch 'main' into dd/implement-test-bracket-builtins
AlexandreYang Mar 10, 2026
222428e
Merge branch 'main' into dd/implement-test-bracket-builtins
datadog-prod-us1-4[bot] Mar 10, 2026
045676a
Merge branch 'main' into dd/implement-test-bracket-builtins
AlexandreYang Mar 10, 2026
d8503ca
Merge branch 'main' into dd/implement-test-bracket-builtins
AlexandreYang Mar 10, 2026
e62eecd
Address security audit review comments
AlexandreYang Mar 10, 2026
2c0395c
Fix build: use MakeFlags instead of Run in testcmd Command structs
AlexandreYang Mar 10, 2026
b0db425
Address code review warnings: Windows directory access + nil guards
AlexandreYang Mar 10, 2026
4caa800
Merge remote-tracking branch 'origin/main' into dd/implement-test-bra…
AlexandreYang Mar 10, 2026
d1587a0
Address code review: nil guard, explicit Close, fix YAML bash skips
AlexandreYang Mar 10, 2026
619d206
Remove pflag symbols from allowlist per PR #27 MakeFlags refactor
AlexandreYang Mar 10, 2026
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
93 changes: 93 additions & 0 deletions interp/allowed_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,65 @@ func (s *pathSandbox) resolve(absPath string) (*os.Root, string, bool) {
return nil, "", false
}

// access checks whether the resolved path is accessible with the given mode.
// All operations go through os.Root to stay within the sandbox.
// Mode: 0x04 = read, 0x02 = write, 0x01 = execute.
func (s *pathSandbox) access(ctx context.Context, path string, mode uint32) error {
absPath := toAbs(path, HandlerCtx(ctx).Dir)

if s == nil {
return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission}
}
for _, ar := range s.roots {
rel, err := filepath.Rel(ar.absPath, absPath)
if err != nil {
continue
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
continue
}

// Open through os.Root once. This checks read access and gives
// us a file descriptor for an atomic Stat (no TOCTOU window).
f, err := ar.root.Open(rel)
if err != nil {
if mode&0x04 != 0 && !isErrIsDirectory(err) {
return portablePathError(err)
}
// Read not requested, or target is a directory; fall back to Stat.
info, serr := ar.root.Stat(rel)
if serr != nil {
return portablePathError(serr)
}
if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) {
return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission}
}
return nil
}

// For write and execute, use mode bits from f.Stat() on the
// open fd — atomic, no TOCTOU window.
// The sandbox is read-only so -w is informational only.
// effectiveHasPerm checks the permission class (owner/group/other)
// that applies to the current process's effective UID/GID on Unix,
// rather than the union of all classes.
if mode&0x03 != 0 {
info, err := f.Stat()
if err != nil {
f.Close()
return portablePathError(err)
}
if !effectiveHasPerm(info, 0222, 0111, mode&0x02 != 0, mode&0x01 != 0) {
f.Close()
return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission}
}
}
f.Close()
return nil
}
return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission}
Comment thread
AlexandreYang marked this conversation as resolved.
}

// toAbs resolves path against cwd when it is not already absolute.
func toAbs(path, cwd string) string {
if filepath.IsAbs(path) {
Expand Down Expand Up @@ -133,6 +192,40 @@ func (s *pathSandbox) readDir(ctx context.Context, path string) ([]fs.DirEntry,
return entries, nil
}

// stat implements the restricted stat policy. The file is resolved through
// os.Root for atomic path validation, matching the open policy.
func (s *pathSandbox) stat(ctx context.Context, path string) (fs.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}
}

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

// lstat implements the restricted lstat policy. Unlike stat, it does not
// follow symlinks — it returns information about the link itself.
func (s *pathSandbox) lstat(ctx context.Context, path string) (fs.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}
}

info, err := root.Lstat(relPath)
if err != nil {
return nil, portablePathError(err)
}
return info, 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
11 changes: 11 additions & 0 deletions interp/builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"fmt"
"io"
"io/fs"
"os"

"github.com/spf13/pflag"
Expand Down Expand Up @@ -80,6 +81,16 @@ 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 (follows symlinks).
StatFile func(ctx context.Context, path string) (fs.FileInfo, error)

// LstatFile returns file info within the shell's path restrictions (does not follow symlinks).
LstatFile func(ctx context.Context, path string) (fs.FileInfo, error)

// AccessFile checks whether the file at path is accessible with the given mode
// within the shell's path restrictions. Mode: 0x04=read, 0x02=write, 0x01=execute.
AccessFile func(ctx context.Context, path string, mode uint32) error

// PortableErr normalizes an OS error to a POSIX-style message.
PortableErr func(err error) string
}
Expand Down
Loading