Skip to content
Draft
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
1 change: 1 addition & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ `ip route get ADDRESS` — show the route selected by longest-prefix-match for ADDRESS (Linux only); write ops (`add`, `del`, `flush`, `replace`, `change`, `save`, `restore`) are blocked; `-6` (IPv6 routing) is not supported
- ✅ `sort [-rnhubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-h`/`--human-numeric-sort` orders by SI suffix (none < K/k < M < G < T < P < E < Z < Y < R < Q) then by numeric value (single-letter suffixes only — `Ki`, `Mi`, etc. are not recognised); `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec)
- ✅ `ss [-tuaxlans4689Hoehs] [OPTION]...` — display network socket statistics; reads kernel socket state directly via `os.Open` (bypassing `AllowedPaths`) from: Linux: `/proc/net/`; macOS: sysctl; Windows: iphlpapi.dll; `-F`/`--filter` (GTFOBins file-read), `-p`/`--processes` (PID disclosure), `-K`/`--kill`, `-E`/`--events`, and `-N`/`--net` are rejected
- ✅ `logrotate [-s SIZE] [-k N] [-v] FILE...` — demo log-rotation helper that truncates each FILE to zero through `AllowedPaths`; `-s SIZE` skips files smaller than SIZE (binary `K/M/G/T` or decimal `KB/MB/GB/TB` suffixes); `-k N` is recorded for `-v` reporting only — the sandbox has no rename capability so prior copies are not retained; `-v` prints a per-file `truncated` or `skipping` line. Not a substitute for real `logrotate(8)`: no config file, compression, rename-based rotation, or post-rotate scripts.
- ✅ `ls [-1aAdFhlpRrSt] [--offset N] [--limit N] [FILE]...` — list directory contents; `--offset`/`--limit` are non-standard pagination flags (single-directory only, silently ignored with `-R` or multiple arguments, capped at 1,000 entries per call); offset operates on filesystem order (not sorted order) for O(n) memory
- ✅ `ping [-c N] [-W DURATION] [-i DURATION] [-q] [-4|-6] [-h] HOST` — send ICMP echo requests to a network host and report round-trip statistics; `-f` (flood), `-b` (broadcast), `-s` (packet size), `-I` (interface), `-p` (pattern), and `-R` (record route) are blocked; count/wait/interval are clamped to safe ranges with a warning; multicast, unspecified (`0.0.0.0`/`::`), and broadcast addresses (IPv4 last-octet `.255`) are rejected — note: directed broadcasts on non-standard subnets (e.g. `.127` on a `/25`) are not blocked without subnet-mask knowledge
- ✅ `ps [-e|-A] [-f] [-p PIDLIST]` — report process status; default shows current-session processes; `-e`/`-A` shows all; `-f` adds UID/PPID/STIME columns; `-p` selects by PID list
Expand Down
60 changes: 60 additions & 0 deletions allowedpaths/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,66 @@ func (s *Sandbox) Truncate(path string, cwd string, size int64, create bool) err
return closeErr
}

// TruncateIfLarger opens path with O_WRONLY (and optionally O_CREATE),
// fstats the resulting fd, and ftruncates to newSize only when the fd's
// pre-truncation size is at least minSize. The fstat and ftruncate share
// the same fd so the size check cannot race with a path swap: a file
// substituted under the same path between resolve and open is caught by
// the IsRegular check inherited from Truncate, and any size-based decision
// is made against the inode that will actually be truncated.
//
// minSize == 0 is equivalent to Truncate(path, cwd, newSize, create) but
// still returns the pre-truncation size so callers can report it. When
// minSize > 0 and the file is smaller, the function returns
// (sizeBefore, false, nil) without altering the file.
//
// All other safety properties — sandbox path resolution, non-regular
// file rejection, write-symlink TOCTOU rejection, EINVAL for negative
// sizes, deferred-close error semantics — match Truncate exactly.
func (s *Sandbox) TruncateIfLarger(path, cwd string, minSize, newSize int64, create bool) (int64, bool, error) {
if newSize < 0 {
return 0, false, &os.PathError{Op: "truncate", Path: path, Err: syscall.EINVAL}
}

absPath := toAbs(path, cwd)

ar, relPath, ok := s.resolve(absPath)
if !ok {
return 0, false, &os.PathError{Op: "truncate", Path: path, Err: os.ErrPermission}
}

flag := os.O_WRONLY | syscall.O_NONBLOCK
if create {
flag |= os.O_CREATE
}
f, err := ar.root.OpenFile(relPath, flag, 0666)
if err != nil {
return 0, false, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return 0, false, err
}
if !info.Mode().IsRegular() {
f.Close()
return 0, false, &os.PathError{Op: "truncate", Path: path, Err: errors.New("not a regular file")}
}
sizeBefore := info.Size()
if sizeBefore < minSize {
// Close-only path: nothing was written, so a Close error here
// cannot mask user-visible data loss. Drop it.
f.Close()
return sizeBefore, false, nil
}
truncErr := f.Truncate(newSize)
closeErr := f.Close()
if truncErr != nil {
return sizeBefore, false, truncErr
}
return sizeBefore, true, closeErr
}

// ReadDir implements the restricted directory-read policy.
func (s *Sandbox) ReadDir(path string, cwd string) ([]fs.DirEntry, error) {
return s.readDirN(path, cwd, -1)
Expand Down
96 changes: 96 additions & 0 deletions allowedpaths/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,102 @@ func TestSandboxTruncateMethodSymlinkEscapeRejected(t *testing.T) {
assert.Equal(t, "untouched", string(got), "symlink target must not be reachable for writes")
}

// TestSandboxTruncateIfLargerAboveThreshold verifies that a file at or
// above minSize is truncated to newSize and the pre-size is reported.
func TestSandboxTruncateIfLargerAboveThreshold(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "log.txt")
require.NoError(t, os.WriteFile(path, []byte("0123456789"), 0644))

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

sizeBefore, truncated, err := sb.TruncateIfLarger("log.txt", dir, 5, 0, false)
require.NoError(t, err)
assert.Equal(t, int64(10), sizeBefore)
assert.True(t, truncated)

info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, int64(0), info.Size())
}

// TestSandboxTruncateIfLargerBelowThreshold verifies that a file smaller
// than minSize is left untouched and (size, false, nil) is returned.
func TestSandboxTruncateIfLargerBelowThreshold(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "log.txt")
require.NoError(t, os.WriteFile(path, []byte("abc"), 0644))

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

sizeBefore, truncated, err := sb.TruncateIfLarger("log.txt", dir, 1024, 0, false)
require.NoError(t, err)
assert.Equal(t, int64(3), sizeBefore)
assert.False(t, truncated)

got, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "abc", string(got), "below-threshold file must not be modified")
}

// TestSandboxTruncateIfLargerZeroMinSize verifies that minSize == 0
// is equivalent to Truncate: the file is always truncated.
func TestSandboxTruncateIfLargerZeroMinSize(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "log.txt")
require.NoError(t, os.WriteFile(path, []byte("xyz"), 0644))

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

sizeBefore, truncated, err := sb.TruncateIfLarger("log.txt", dir, 0, 0, false)
require.NoError(t, err)
assert.Equal(t, int64(3), sizeBefore)
assert.True(t, truncated)

info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, int64(0), info.Size())
}

// TestSandboxTruncateIfLargerOutsideAllowedPath verifies that paths
// outside the sandbox are rejected with permission denied before any I/O.
func TestSandboxTruncateIfLargerOutsideAllowedPath(t *testing.T) {
allowed := t.TempDir()
outside := t.TempDir()
target := filepath.Join(outside, "log.txt")
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0644))

sb, _, err := New([]string{allowed})
require.NoError(t, err)
defer sb.Close()

_, _, err = sb.TruncateIfLarger(target, allowed, 0, 0, false)
assert.ErrorIs(t, err, os.ErrPermission)

got, ferr := os.ReadFile(target)
require.NoError(t, ferr)
assert.Equal(t, "untouched", string(got))
}

// TestSandboxTruncateIfLargerNoCreate verifies that missing files surface
// os.ErrNotExist when create=false.
func TestSandboxTruncateIfLargerNoCreate(t *testing.T) {
dir := t.TempDir()

sb, _, err := New([]string{dir})
require.NoError(t, err)
defer sb.Close()

_, _, err = sb.TruncateIfLarger("missing.txt", dir, 0, 0, false)
assert.ErrorIs(t, err, fs.ErrNotExist)
}

func TestSandboxOpenReadStillWorks(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("data"), 0644))
Expand Down
6 changes: 6 additions & 0 deletions analysis/symbols_builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ var builtinPerCommandSymbols = map[string][]string{
"os.O_RDONLY", // 🟢 read-only file flag constant; cannot open files by itself.
"strconv.ParseInt", // 🟢 string-to-int conversion with base/bit-size; pure function, no I/O.
},
"logrotate": {
"context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects.
"errors.New", // 🟢 creates a sentinel error value; pure function, no I/O.
"math.MaxInt64", // 🟢 integer constant; no side effects.
"strconv.ParseInt", // 🟢 string-to-int conversion with base/bit-size; pure function, no I/O.
},
"ls": {
"context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects.
"errors.New", // 🟢 creates a simple error value; pure function, no I/O.
Expand Down
9 changes: 9 additions & 0 deletions builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ type CallContext struct {
// Negative sizes are rejected.
Truncate func(ctx context.Context, path string, size int64, create bool) error

// TruncateIfLarger opens path through the sandbox, fstats the open
// fd, and ftruncates to newSize only when the pre-truncation size is
// at least minSize. The size check and truncate share a single fd so
// the threshold cannot race a path swap. Returns the pre-truncation
// size (always populated when the open succeeds) and a flag indicating
// whether ftruncate ran. When minSize == 0 the check is skipped and
// the file is always truncated.
TruncateIfLarger func(ctx context.Context, path string, minSize, newSize int64, create bool) (sizeBefore int64, truncated bool, err error)

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

Expand Down
Loading