Skip to content
Merged
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
10 changes: 10 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ type SandboxConfig struct {
// with no kernel mechanism every fenced operation is refused instead of
// being silently allowed.
RequireHardBoundary bool `json:"require_hard_boundary,omitempty"`

// ConfineCommands runs shell commands under the kernel file boundary
// instead of only under the string checks. Off by default, and worth
// turning on only with the consequence understood: a confined command can
// write only under the session's writable roots, so any toolchain that
// writes its own cache outside them will fail.
ConfineCommands bool `json:"confine_commands,omitempty"`
}

// MCPServerConfig defines a single MCP server to connect to.
Expand Down Expand Up @@ -252,6 +259,9 @@ func merge(dst, src Config) Config {
if src.Sandbox.RequireHardBoundary {
dst.Sandbox.RequireHardBoundary = true
}
if src.Sandbox.ConfineCommands {
dst.Sandbox.ConfineCommands = true
}
}

// Merge agent overrides
Expand Down
14 changes: 14 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,13 +504,27 @@ func applySandboxCapabilityPolicy(cfg *config.Config) {
if cfg.Sandbox != nil && cfg.Sandbox.RequireHardBoundary {
tool.SetRequireHardBoundary(true)
}
if cfg.Sandbox != nil && cfg.Sandbox.ConfineCommands {
tool.SetConfineCommands(true)
}
info := tool.ContainmentInfo()
tlog.Info("sandbox", "containment", info.String())
if tool.HardBoundaryRequired() && info.Level != tool.ContainmentKernel {
tlog.Warn("sandbox", "hard_boundary_unavailable",
"containment", info.String(),
"effect", "fenced file operations will be refused")
}
if tool.ConfineCommands() {
// Confining a subprocess is a different capability from confining our
// own opens, so it gets its own verdict: a host can enforce one and not
// the other, and the caller needs to know which one it configured.
available := tool.CommandConfinementAvailable()
tlog.Info("sandbox", "command_confinement", "requested", true, "available", available)
if !available {
tlog.Warn("sandbox", "command_confinement_unavailable",
"effect", "every shell command will be refused rather than run unconfined")
}
}
}

// expandPath expands $VARS and a leading "~" in a configured path, so values
Expand Down
37 changes: 36 additions & 1 deletion tool/bash.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tool
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
Expand All @@ -13,6 +14,15 @@ import (
"github.com/yusiwen/tinycode/types"
)

// exitCodeOf reports the exit status a finished command failed with, if it did.
func exitCodeOf(err error) (int, bool) {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), true
}
return 0, false
}

// maxOutputBytes caps how much of each stream (stdout/stderr) is retained, so
// a runaway command cannot exhaust the agent's memory.
const maxOutputBytes = 1 << 20 // 1 MiB per stream
Expand Down Expand Up @@ -220,7 +230,14 @@ func Bash() Tool {
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()

cmd := exec.CommandContext(cmdCtx, "bash", "-c", cmdStr)
// Confinement wraps the same ['bash','-c',cmd] argv, so the command
// the caller asked for is unchanged; only what enforces its file
// effects differs.
argv, invErr := bashInvocation(ctx, cmdStr)
if invErr != nil {
return "", invErr
}
cmd := exec.CommandContext(cmdCtx, argv[0], argv[1:]...)
// Run the shell in its own process group and kill the whole group
// on timeout, so background children do not survive the call.
configureProcessGroup(cmd)
Expand Down Expand Up @@ -291,6 +308,24 @@ func Bash() Tool {
}

var sb strings.Builder

// A launcher failure is not a command failure: the command never
// ran, and reporting it as an ordinary error would send the reader
// looking for a bug in their command. Both the exit status and our
// own diagnostic prefix must match, so a command that exits 125 by
// itself is not read as a sandbox report.
if ConfineCommands() {
if code, ok := exitCodeOf(err); ok && SandboxLauncherFailed(code, stderr.String()) {
tlog.Warn("shell.bash", "sandbox_launcher_failed", "exit", code)
sb.WriteString("\n[SANDBOX] the command did not run: the file boundary could not be applied on this host.\n")
for _, line := range SandboxLauncherDiagnostics(stderr.String()) {
sb.WriteString(" " + line + "\n")
}
sb.WriteString("Do not retry: a kernel boundary is a capability of the machine, not a permission that can be granted.\n")
return strings.TrimSpace(sb.String()), nil
}
}

if stdout.Len() > 0 {
sb.WriteString("STDOUT:\n")
sb.WriteString(stdout.String())
Expand Down
169 changes: 169 additions & 0 deletions tool/bash_confinement_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package tool

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/yusiwen/tinycode/types"
)

// withCommandConfinement turns the policy on for one test and restores it.
func withCommandConfinement(t *testing.T, confined bool) {
t.Helper()
previous := ConfineCommands()
SetConfineCommands(confined)
t.Cleanup(func() { SetConfineCommands(previous) })
}

// withLauncher stands in a script for the confinement launcher and restores the
// real lookup afterwards.
func withLauncher(t *testing.T, script string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "launcher")
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
previous := sandboxSelfPath
sandboxSelfPath = func() (string, error) { return path, nil }
t.Cleanup(func() { sandboxSelfPath = previous })
}

func TestBashInvocationWithoutConfinement(t *testing.T) {
withCommandConfinement(t, false)

got, err := bashInvocation(context.Background(), "echo hi")
if err != nil {
t.Fatalf("bashInvocation: %v", err)
}
want := []string{"bash", "-c", "echo hi"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("argv = %q, want %q", got, want)
}
}

// TestBashInvocationConfinedMapsModeAndRoots pins what the boundary is given:
// plan mode is read-only and grants no path, build mode is workspace-write over
// exactly the session's writable roots.
func TestBashInvocationConfinedMapsModeAndRoots(t *testing.T) {
withCommandConfinement(t, true)

root := t.TempDir()
saved := DefaultSandbox
DefaultSandbox = &SandboxConfig{
ProjectRoot: root,
AutoAllowPaths: []string{root},
allowedPaths: map[string]bool{},
}
defer func() { DefaultSandbox = saved }()

t.Run("plan mode", func(t *testing.T) {
ctx := types.WithPlanWriteRestriction(context.Background(), true)
argv, err := bashInvocation(ctx, "echo hi")
if err != nil {
t.Fatal(err)
}
joined := strings.Join(argv, " ")
if !strings.Contains(joined, "--mode read-only") {
t.Fatalf("argv = %q, want the read-only mode", joined)
}
if strings.Contains(joined, "--allow") {
t.Fatalf("argv = %q, want no granted path under read-only", joined)
}
if !strings.HasSuffix(joined, "-- bash -c echo hi") {
t.Fatalf("argv = %q, want the original command after the separator", joined)
}
})

t.Run("build mode", func(t *testing.T) {
argv, err := bashInvocation(context.Background(), "echo hi")
if err != nil {
t.Fatal(err)
}
joined := strings.Join(argv, " ")
if !strings.Contains(joined, "--mode workspace-write") {
t.Fatalf("argv = %q, want the workspace-write mode", joined)
}
for _, r := range DefaultSandbox.WritableRoots() {
if !strings.Contains(joined, "--allow "+r) {
t.Fatalf("argv = %q, want --allow %s", joined, r)
}
}
})
}

// TestBashInvocationSurfacesLauncherLookupFailure covers the one failure that
// happens before anything runs: without a launcher path there is no boundary,
// so the command must not be run at all.
func TestBashInvocationSurfacesLauncherLookupFailure(t *testing.T) {
withCommandConfinement(t, true)

previous := sandboxSelfPath
sandboxSelfPath = func() (string, error) { return "", os.ErrNotExist }
defer func() { sandboxSelfPath = previous }()

if _, err := bashInvocation(context.Background(), "echo hi"); err == nil {
t.Fatal("bashInvocation succeeded without a launcher, want an error")
}
}

// TestBashReportsLauncherFailureAsNotRun is the classification test: when the
// boundary could not be applied, the result says the command did not run, and
// it reads the launcher's own diagnostic rather than whatever the command
// printed.
func TestBashReportsLauncherFailureAsNotRun(t *testing.T) {
withCommandConfinement(t, true)
withLauncher(t, "#!/bin/sh\necho 'tinycode-sandbox: stub could not confine' >&2\necho 'command output that never happened'\nexit 125\n")

result, err := Bash().Execute(context.Background(), map[string]any{"command": "echo hi"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(result, "[SANDBOX]") || !strings.Contains(result, "did not run") {
t.Fatalf("result = %q, want it to say the command did not run", result)
}
if !strings.Contains(result, "stub could not confine") {
t.Fatalf("result = %q, want the launcher's own diagnostic", result)
}
// The command never ran, so its output must not be presented as if it had.
if strings.Contains(result, "command output that never happened") {
t.Fatalf("result = %q, want no command output attributed to a command that did not run", result)
}
}

// TestBashConfinedCommandBehavesNormally is the other half: with the boundary
// applied successfully, the command runs and its output arrives unchanged.
func TestBashConfinedCommandBehavesNormally(t *testing.T) {
withCommandConfinement(t, true)
// A stand-in that applies nothing but does what the real launcher does with
// the argv: skip its own options, then exec the command.
withLauncher(t, "#!/bin/sh\nwhile [ $# -gt 0 ] && [ \"$1\" != \"--\" ]; do shift; done\nshift\nexec \"$@\"\n")

result, err := Bash().Execute(context.Background(), map[string]any{"command": "echo confined-hello"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(result, "confined-hello") {
t.Fatalf("result = %q, want the command's output", result)
}
}

// TestSandboxLauncherDiagnosticsFiltersCommandOutput pins that the sandbox's
// report is only what the launcher wrote: a command that prints words
// resembling our diagnostic contributes nothing to it.
func TestSandboxLauncherDiagnosticsFiltersCommandOutput(t *testing.T) {
stderr := "ordinary command noise\n" +
SandboxLauncherDiagnosticPrefix + "cannot apply the file boundary: invalid argument\n" +
"the command printed this\n"

got := SandboxLauncherDiagnostics(stderr)
if len(got) != 1 {
t.Fatalf("diagnostics = %q, want exactly the launcher's line", got)
}
if got[0] != "cannot apply the file boundary: invalid argument" {
t.Fatalf("diagnostic = %q", got[0])
}
}
16 changes: 16 additions & 0 deletions tool/confinement_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"os"
"sync"
"unsafe"

"golang.org/x/sys/unix"
Expand Down Expand Up @@ -82,6 +83,21 @@ func runConfined(spec launcherSpec) int {
// errLandlockUnavailable reports a host without a usable Landlock.
var errLandlockUnavailable = errors.New("this kernel has no usable Landlock")

var (
commandConfinementOnce sync.Once
commandConfinementOK bool
)

// commandConfinementAvailable answers the launcher-capability question once: the
// ABI probe is a syscall, and the answer cannot change during a run.
func commandConfinementAvailable() bool {
commandConfinementOnce.Do(func() {
_, err := landlockABI()
commandConfinementOK = err == nil
})
return commandConfinementOK
}

// applyLandlock restricts the calling process: write-class access is denied
// everywhere except /dev/null and the given roots.
//
Expand Down
4 changes: 4 additions & 0 deletions tool/confinement_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ package tool
func runConfined(_ launcherSpec) int {
return launcherFail("no kernel file-boundary mechanism for subprocesses on this platform")
}

// commandConfinementAvailable is false here for the same reason: there is no
// mechanism to apply.
func commandConfinementAvailable() bool { return false }
21 changes: 21 additions & 0 deletions tool/containment.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ func SetRequireHardBoundary(required bool) { requireHardBoundary.Store(required)
// HardBoundaryRequired reports whether that policy is on.
func HardBoundaryRequired() bool { return requireHardBoundary.Load() }

var confineCommands atomic.Bool

// SetConfineCommands turns command confinement on or off: when on, every shell
// command runs under the kernel file boundary instead of only under the string
// checks.
//
// It is off by default and is a deliberate choice, not a default: a confined
// command can write only under the session's writable roots, so toolchains that
// write their own caches (GOCACHE, ~/.npm, …) fail unless those roots are
// granted. Flipping it on changes what every command in the session can do.
func SetConfineCommands(confined bool) { confineCommands.Store(confined) }

// ConfineCommands reports whether shell commands run under the boundary.
func ConfineCommands() bool { return confineCommands.Load() }

// CommandConfinementAvailable reports whether this host can confine a
// subprocess at all. It is a different question from ContainmentInfo: a
// platform can enforce the agent's own opens (the macOS component walk) and
// still have no way to confine a command it spawns.
func CommandConfinementAvailable() bool { return commandConfinementAvailable() }

// ErrHardBoundaryUnavailable is returned when the policy demands a kernel
// boundary and the host has none.
//
Expand Down
Loading
Loading