sandbox

package
v1.2.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Index

Constants

View Source
const (
	MetadataRequestKey = "sandbox_request"
	MetadataPreviewKey = "sandbox_preview"
)

Variables

This section is empty.

Functions

func BuildToolPermissionRequest

func BuildToolPermissionRequest(req PermissionRequest, opts ToolPermissionOptions) (types.ToolPermissionRequest, error)

BuildToolPermissionRequest converts a normalized sandbox request into the runtime permission request expected by the engine.

func EnvSliceToMap

func EnvSliceToMap(env []string) map[string]string

enviroToMap converts an os.Environ()-style slice to a map. Exported for use in tests.

func ErrorForDecision

func ErrorForDecision(result DecisionResult) error

ErrorForDecision converts a normalized decision into a conventional Go error.

func ErrorForPermissionResult

func ErrorForPermissionResult(result types.PermissionResult, fallbackReason string) error

ErrorForPermissionResult converts a runtime permission result into a conventional Go error.

func RegisterRemoteExecutor added in v0.2.3

func RegisterRemoteExecutor(sessionID string, executor Executor)

RegisterRemoteExecutor attaches executor as sessionID's remote execution backend. A re-registration (e.g. a reconnect) replaces any previous one.

func ResolveToolPermission

func ResolveToolPermission(
	ctx context.Context,
	checker types.CanUseToolFn,
	req PermissionRequest,
	opts ToolPermissionOptions,
) (types.PermissionResult, error)

ResolveToolPermission runs the normalized request through the active permission resolver.

func UnregisterRemoteExecutor added in v0.2.3

func UnregisterRemoteExecutor(sessionID string)

UnregisterRemoteExecutor detaches sessionID's remote execution backend — subsequent bash calls for that session fall back to local execution.

Types

type AccessKind

type AccessKind string

AccessKind describes the resource access being requested.

const (
	AccessRead     AccessKind = "read"
	AccessWrite    AccessKind = "write"
	AccessCreate   AccessKind = "create"
	AccessDelete   AccessKind = "delete"
	AccessSearch   AccessKind = "search"
	AccessExecute  AccessKind = "execute"
	AccessNetwork  AccessKind = "network"
	AccessEscalate AccessKind = "escalate"
)

type ApprovalRequiredError

type ApprovalRequiredError struct {
	Reason string
}

ApprovalRequiredError is returned when an action is valid but needs approval.

func (*ApprovalRequiredError) Error

func (e *ApprovalRequiredError) Error() string

type ApprovalScope

type ApprovalScope string

ApprovalScope controls how long an approval grant should remain valid.

const (
	ApprovalScopeToolCall ApprovalScope = "tool_call"
	ApprovalScopeTurn     ApprovalScope = "turn"
	ApprovalScopeSession  ApprovalScope = "session"
)

type CommandPolicy

type CommandPolicy struct {
	// contains filtered or unexported fields
}

CommandPolicy is the single authoritative command safety classifier. It replaces the scattered PermissionValidator in the bash package.

Two-tier model (mirrors Codex):

  • IsKnownSafe: explicit allowlist → bypass approval entirely
  • Evaluate: deny/ask/allow based on danger fragments and command type

func NewDefaultCommandPolicy

func NewDefaultCommandPolicy() *CommandPolicy

func (*CommandPolicy) AskCommands added in v1.1.0

func (p *CommandPolicy) AskCommands() []string

AskCommands returns the bare command names that always require explicit approval, for introspection (e.g. the get_config tool).

func (*CommandPolicy) DenyFragments added in v1.1.0

func (p *CommandPolicy) DenyFragments() []string

DenyFragments returns the substrings that cause a command to be hard-denied regardless of mode, for introspection (e.g. the get_config tool).

func (*CommandPolicy) DenyPatterns added in v1.1.0

func (p *CommandPolicy) DenyPatterns() []string

DenyPatterns returns the regex source strings that cause a command to be hard-denied regardless of mode, for introspection (e.g. the get_config tool).

func (*CommandPolicy) Evaluate

func (p *CommandPolicy) Evaluate(command string) DecisionResult

Evaluate returns the policy decision for a command.

Order of evaluation:

  1. Known-safe allowlist → Allow (no approval prompt needed)
  2. Shell wrapper → evaluate composed inner
  3. Deny fragment match → Deny
  4. Ask-command list → Ask
  5. Command type classification: write/vcs/unknown → Ask, read/search/state → Allow

func (*CommandPolicy) IsKnownSafe

func (p *CommandPolicy) IsKnownSafe(command string) bool

IsKnownSafe returns true when every sub-command in the expression is on the explicit safe allowlist, meaning the entire command can be executed without any approval prompt.

Mirrors Codex's is_known_safe_command. If the command is a shell wrapper (bash -c "…") the inner script is split into segments and each is checked.

type Context

type Context struct {
	WorkingDirectory string
	WorkspaceRoot    string
	AdditionalRoots  []string
	Environment      EnvironmentKind
	SandboxEnabled   bool
}

Context carries the execution boundary relevant to sandbox decisions.

func (Context) ResolvePath

func (c Context) ResolvePath(path string) (string, error)

ResolvePath resolves a candidate path according to the current execution context. When a WorkspaceRoot is configured, relative paths are anchored to the root and validated to stay inside it. Absolute paths bypass workspace containment so the user can approve writes to /tmp or other out-of-workspace directories — the FilesystemPolicy dangerous-prefix check still applies downstream.

type Decision

type Decision string

Decision is the normalized outcome of a sandbox/policy check.

const (
	DecisionAllow Decision = "allow"
	DecisionAsk   Decision = "ask"
	DecisionDeny  Decision = "deny"
)

type DecisionResult

type DecisionResult struct {
	Decision Decision
	Reason   string
}

DecisionResult is the normalized output of a policy decision.

type DockerExecutor added in v0.2.3

type DockerExecutor struct {
	// contains filtered or unexported fields
}

DockerExecutor runs commands inside isolated Docker containers via the docker CLI (os/exec with structured argv — request text is passed as a single argv element to `sh -c`, never interpolated into a shell string; same safety pattern as NoopExecutor.Run).

Each environment (RunRequest.Docker.EnvironmentID) gets one long-lived container, created lazily on first use and reused via `docker exec` for every subsequent call — this is what makes state (installed packages, background processes, …) persist across calls within the same environment, without spinning a fresh container per command.

Isolation properties enforced: filesystem and process-namespace isolation via the container boundary, hard CPU/memory caps (--cpus/--memory), and optional full network isolation (--network=none). These are the two properties an earlier Dagger-SDK-based design could not provide — Dagger's public Go API (as of v0.21) exposes neither per-container resource limits nor a way to disable network access for a WithExec step.

Scope: this backs local, single-user desktop sandboxing — not a multi-tenant sandbox-as-a-service platform. Multi-tenant concerns (Kubernetes/distributed scheduling, an ingress gateway for exposing sandbox-hosted services to third parties, a standalone credential vault, a formal third-party-runtime lifecycle protocol) are deliberately NOT built here — if a use case ever needs that, the right move is a sandbox.Executor adapter for an existing open-source platform (e.g. OpenSandbox) rather than reimplementing a worse copy of it inside seshat.

Deliberately not attempted — --cap-drop=ALL: Docker's own default capability set is already reduced (not ALL), and dropping further risks breaking legitimate tools (e.g. CAP_SETUID/CAP_SETGID, which privilege-dropping install scripts rely on) for a security benefit with no articulated compromise scenario it would prevent that NET_RAW/NET_ADMIN (already dropped below) and runtime selection (below) don't already cover.

Done:

  • Strong isolation runtime selection (DockerExecutorConfig.Runtime, e.g. "runsc" for gVisor) — plumbed and tested against `runc` as a stand-in (this machine has no gVisor installed to verify against for real: `docker info` lists only runc/nvidia-container-runtime). Actually installing and registering gVisor with the Docker daemon is an operator/host setup step outside what Go code here can do; Healthy(ctx) refuses to proceed if the configured Runtime isn't registered, rather than silently falling back to plain runc.
  • Orphan container cleanup (labels + reaper on construction, see reapOrphanContainers) — a crashed host process's containers are cleaned up by the next one, not left running indefinitely.
  • NET_RAW/NET_ADMIN dropped by default (--cap-drop) — narrows the capability set Docker already reduces further, with no realistic legitimate-tool breakage (package managers/builds/git all use ordinary TCP, not raw sockets or network device configuration).
  • Port tunneling (DockerExecutorConfig.PublishPorts / RunResult.Endpoints) — useful even for local usage (e.g. previewing a dev server an agent started inside the sandbox). Declared once at the environment level, not per RunRequest, because Docker only allows publishing ports at container-creation time.
  • Git-tracked history of file changes (DockerExecutorConfig. TrackFileChanges) — a product feature (undo/diff of what an agent did), not a security property, but implemented: snapshots RunRequest.WorkDir into a separate bare shadow repo (never the project's own .git, which is explicitly excluded) after any Run call that actually changed something, keyed by a hash of the absolute WorkDir path so history survives across environment/process restarts for the same project directory. Off by default (requires git on the host; Healthy(ctx) fails loudly rather than silently skipping tracking if TrackFileChanges is set but git can't be found).

Already true today, not a gap: RunRequest.Env / DockerExecutorConfig.Env are the only environment variables that reach the container — unlike NoopExecutor.Run (which inherits the full host process environment via cmd.Environ()), DockerExecutor never implicitly forwards os.Environ() into the sandbox, so host-side API keys and other secrets the seshat process holds are not exposed to a sandboxed command unless explicitly passed.

func (*DockerExecutor) Close added in v0.2.3

func (e *DockerExecutor) Close() error

Close stops and removes every environment container this executor has created (--rm at creation makes stop trigger automatic removal). Safe to call multiple times. Host applications should call this at shutdown — DockerExecutor is not wired into any automatic engine-wide shutdown hook yet, so the caller that owns the Executor is responsible for this today.

func (*DockerExecutor) Healthy added in v0.2.3

func (e *DockerExecutor) Healthy(ctx context.Context) error

Healthy runs `docker version` against the daemon. A non-nil error means Docker isn't installed, isn't running, or isn't reachable — the engine calls this once at startup so the host application can fall back (typically to NoopExecutor with a visible warning) instead of failing every subsequent bash call individually.

func (*DockerExecutor) Kind added in v0.2.3

func (e *DockerExecutor) Kind() EnvironmentKind

func (*DockerExecutor) Run added in v0.2.3

type DockerExecutorConfig added in v0.2.3

type DockerExecutorConfig struct {
	// BaseImage is the OCI image used for new environments.
	// Default: "ubuntu:24.04"
	BaseImage string

	// SetupCommands are shell commands run once when an environment's
	// container is first created.
	// Example: ["apt-get update -y", "apt-get install -y python3 nodejs"]
	SetupCommands []string

	// WorkDir is the working directory inside the container that
	// RunRequest.WorkDir (a host path) is bind-mounted to.
	// Default: "/workdir"
	WorkDir string

	// Env is a set of KEY=VALUE pairs always injected into the container.
	Env map[string]string

	// NetworkAccess controls whether the container can reach the network.
	// Default: true (default Docker bridge network).
	// Set to false to run with --network=none.
	NetworkAccess bool

	// MemoryLimitMB caps container memory (docker run --memory).
	// Default: 2048 (2 GiB). 0 means no additional default is applied
	// (Docker's own daemon-level default, if any, still applies).
	MemoryLimitMB int

	// CPULimit caps container CPU (docker run --cpus). Fractional values
	// are allowed (e.g. 1.5). Default: 2.0. 0 means no limit is passed.
	CPULimit float64

	// DockerBinary is the path to the docker CLI. Default: "docker"
	// (resolved via PATH).
	DockerBinary string

	// Runtime selects a non-default OCI runtime (docker run --runtime=…),
	// e.g. "runsc" for gVisor. Empty (default) uses Docker's own default
	// runtime — plain runc, which shares the host kernel with the
	// container. A stronger isolation runtime like gVisor must already be
	// installed and registered with the Docker daemon separately (this is
	// an operator/host setup step, not something DockerExecutor can do) —
	// if Runtime is set but not registered, Healthy(ctx) fails rather than
	// silently falling back to running without it, so an explicit request
	// for stronger isolation never gets silently downgraded.
	Runtime string

	// PublishPorts lists container ports to publish to random host ports
	// when an environment's container is created (docker run -p). Useful
	// for e.g. previewing a dev server an agent starts inside the sandbox.
	// Fixed for the lifetime of an environment — declared here rather than
	// per RunRequest because Docker only allows publishing ports at
	// container-creation time. The actual host-side ports are reported back
	// via RunResult.Endpoints on every call to that environment.
	PublishPorts []int

	// TrackFileChanges enables git-tracked snapshots of RunRequest.WorkDir
	// (the host directory bind-mounted into the environment) after every
	// Run call that actually changed something. Default: false — this is an
	// audit/undo convenience feature, not a security property, and requires
	// git on the HOST (not inside the container). If true and git isn't
	// found on the host, Healthy(ctx) fails rather than silently tracking
	// nothing — same "don't silently downgrade a requested capability"
	// contract as Runtime.
	//
	// Snapshots go into a separate, bare git repository — NOT the
	// project's own .git, if it has one — keyed by a hash of the absolute
	// WorkDir path (stable across environment/container restarts for the
	// same directory, see HistoryDir). The project's own .git, if present,
	// is excluded from what gets snapshotted.
	TrackFileChanges bool

	// HistoryDir is the base directory shadow tracking repos are stored
	// under when TrackFileChanges is true. Default (DefaultDockerConfig):
	// os.UserConfigDir()/seshat/sandbox-history.
	HistoryDir string

	// GitBinary is the path to the git CLI used for TrackFileChanges.
	// Default: "git" (resolved via PATH).
	GitBinary string
}

DockerExecutorConfig holds static configuration for the DockerExecutor. All fields have sensible defaults and can be overridden by the operator.

func DefaultDockerConfig added in v0.2.3

func DefaultDockerConfig() DockerExecutorConfig

DefaultDockerConfig returns a DockerExecutorConfig with sensible, security-conscious defaults — callers only need to override what they actually want different.

type DockerRunOptions added in v0.2.3

type DockerRunOptions struct {
	// EnvironmentID selects a named, persistent environment (one long-lived
	// container reused across calls via `docker exec`). Empty means the
	// default session environment.
	//
	// Port publishing is NOT a per-call option here — Docker only allows
	// publishing ports at container-creation time, not on an already-running
	// container, and an environment's container may already exist by the
	// time a given RunRequest is issued. See
	// DockerExecutorConfig.PublishPorts, which is declared once for the
	// environment instead.
	EnvironmentID string
}

DockerRunOptions carries Docker-specific per-run parameters. These are only meaningful when the active Executor is a DockerExecutor.

type EnvironmentKind

type EnvironmentKind string

EnvironmentKind describes where a tool executes. The policy layer should know this, even if the actual backend runtime (local process, docker, remote host) is implemented elsewhere.

const (
	EnvironmentLocal    EnvironmentKind = "local"
	EnvironmentWorktree EnvironmentKind = "worktree"
	EnvironmentDocker   EnvironmentKind = "docker"
	EnvironmentRemote   EnvironmentKind = "remote"
	EnvironmentUnknown  EnvironmentKind = "unknown"
)

type Executor

type Executor interface {
	// Run executes a command inside the sandbox and streams its output.
	// Implementations must respect ctx cancellation and RunRequest.Timeout.
	Run(ctx context.Context, req RunRequest) (RunResult, error)

	// Kind identifies which backend this is.
	Kind() EnvironmentKind

	// Healthy returns nil when the executor is ready to accept work.
	// A non-nil error means the backend is unavailable (Dagger engine down,
	// Docker socket missing, etc). The engine calls this once at startup.
	Healthy(ctx context.Context) error
}

Executor is the interface all sandbox backends implement. The engine routes bash/shell execution through the active Executor.

Backends:

  • NoopExecutor — runs commands directly on the host OS (default, no isolation)
  • DockerExecutor — runs commands inside an isolated Docker container

Selection at startup via ExecutorConfig.Kind. Tools that are read-only (file read, glob, grep) bypass the Executor and go directly to the host FS.

func NewExecutor

func NewExecutor(cfg ExecutorConfig) (Executor, error)

NewExecutor creates the Executor selected by cfg.Kind. Returns an error when the requested backend is unavailable.

func RemoteExecutorFor added in v0.2.3

func RemoteExecutorFor(sessionID string) (Executor, bool)

RemoteExecutorFor looks up sessionID's registered remote executor, if any.

type ExecutorConfig

type ExecutorConfig struct {
	// Kind selects the backend. Defaults to EnvironmentLocal (noop).
	Kind EnvironmentKind

	// Docker holds Docker-specific configuration.
	// Only used when Kind == EnvironmentDocker.
	Docker DockerExecutorConfig
}

ExecutorConfig is the configuration passed to NewExecutor at startup.

type FilesystemPolicy

type FilesystemPolicy struct {
	// contains filtered or unexported fields
}

FilesystemPolicy centralizes common filesystem access checks.

func NewDefaultFilesystemPolicy

func NewDefaultFilesystemPolicy() *FilesystemPolicy

func (*FilesystemPolicy) EvaluatePath

func (p *FilesystemPolicy) EvaluatePath(ctx Context, path string, access AccessKind) (PathDecision, error)

func (*FilesystemPolicy) ReadDeniedPrefixes added in v1.1.0

func (p *FilesystemPolicy) ReadDeniedPrefixes() []string

ReadDeniedPrefixes returns the path prefixes read access is denied under, for introspection (e.g. the get_config tool) - not a mutable handle.

func (*FilesystemPolicy) WriteDeniedPrefixes added in v1.1.0

func (p *FilesystemPolicy) WriteDeniedPrefixes() []string

WriteDeniedPrefixes returns the path prefixes write access is denied under, for introspection (e.g. the get_config tool) - not a mutable handle.

type NoopExecutor

type NoopExecutor struct{}

NoopExecutor runs commands directly on the host OS with no additional isolation. This is the default executor and mirrors the current behavior of the bash tool before sandboxing was introduced.

Isolation: none. The CommandPolicy and FilesystemPolicy still apply, but there is no OS-level boundary between the agent and the host filesystem.

func NewNoopExecutor

func NewNoopExecutor() *NoopExecutor

NewNoopExecutor returns a NoopExecutor ready to use.

func (*NoopExecutor) Healthy

func (e *NoopExecutor) Healthy(_ context.Context) error

func (*NoopExecutor) Kind

func (e *NoopExecutor) Kind() EnvironmentKind

func (*NoopExecutor) Run

func (e *NoopExecutor) Run(ctx context.Context, req RunRequest) (RunResult, error)

type PathDecision

type PathDecision struct {
	DecisionResult
	ResolvedPath string
}

PathDecision includes the resolved path for filesystem checks.

type PermissionDecision

type PermissionDecision struct {
	Decision      Decision
	Reason        string
	Scope         ApprovalScope
	ApprovedPaths []string
	Metadata      map[string]any
}

PermissionDecision is the normalized response returned by the sandbox/approval layer.

func (PermissionDecision) IsAllowed

func (d PermissionDecision) IsAllowed() bool

IsAllowed reports whether the request was approved.

type PermissionDeniedError

type PermissionDeniedError struct {
	Reason string
}

PermissionDeniedError is returned when the sandbox denies an action outright.

func (*PermissionDeniedError) Error

func (e *PermissionDeniedError) Error() string

type PermissionRequest

type PermissionRequest struct {
	ToolName       string
	Description    string
	Environment    EnvironmentKind
	Access         AccessKind
	Command        string
	Paths          []string
	NetworkTargets []string
	Justification  string
	Scope          ApprovalScope
	Metadata       map[string]any
}

PermissionRequest is the normalized request emitted by tools and runtimes when they need a sandbox/approval decision.

func (PermissionRequest) DescriptionText

func (r PermissionRequest) DescriptionText() string

DescriptionText returns a stable approval-friendly description.

func (PermissionRequest) MetadataMap

func (r PermissionRequest) MetadataMap() map[string]any

MetadataMap returns stable metadata for the shared permission pipeline.

func (PermissionRequest) Validate

func (r PermissionRequest) Validate() error

Validate ensures a permission request is structurally usable by the approval pipeline.

type RunRequest

type RunRequest struct {
	// Command is the shell command string to execute.
	// Passed as-is to the interpreter defined by Shell.
	Command string

	// Shell specifies the interpreter (default: /bin/sh -c).
	// Ignored by DockerExecutor which always uses sh inside the container.
	Shell []string

	// Env is a set of KEY=VALUE pairs injected into the command environment
	// on top of the executor's base environment.
	Env map[string]string

	// WorkDir is the host working directory for the command. NoopExecutor
	// and RemoteExecutor use it directly as the process's cwd. DockerExecutor
	// additionally treats it as the bind-mount source for the environment's
	// container-side working directory (see DockerExecutorConfig.WorkDir) —
	// fixed at the environment's first Run call and reused on every
	// subsequent call to the same Docker.EnvironmentID.
	WorkDir string

	// Stdin is connected to the command's standard input (may be nil).
	Stdin io.Reader

	// Timeout is the maximum duration for the command.
	// Zero means no timeout beyond the parent context deadline.
	Timeout time.Duration

	// Background requests a fire-and-forget execution (no output captured).
	// Used for long-running processes (servers, watchers).
	Background bool

	// Docker-specific extensions — ignored by NoopExecutor.
	Docker *DockerRunOptions
}

RunRequest is the input to Executor.Run.

type RunResult

type RunResult struct {
	// Stdout and Stderr capture the command output.
	// Empty when Background=true.
	Stdout string
	Stderr string

	// ExitCode is the process exit code. Non-zero indicates failure.
	ExitCode int

	// Duration is the wall-clock time the command took.
	Duration time.Duration

	// Cwd is the working directory the command left the environment in.
	// Only populated by executors backed by a persistent shell (e.g.
	// RemoteExecutor) where a `cd` genuinely changes state for the next
	// command; empty for one-shot-per-call executors like NoopExecutor/
	// DockerExecutor where WorkDir never changes out from under the caller.
	Cwd string

	// Endpoints maps container port numbers to "host:port" strings — where
	// on the host a service listening on that container port is reachable.
	// Populated on every call by DockerExecutor when
	// DockerExecutorConfig.PublishPorts is non-empty (the mapping is fixed
	// at environment creation, so it's the same on every call for a given
	// environment, not something a specific RunRequest controls).
	Endpoints map[int]string
}

RunResult is the output of Executor.Run.

type ToolAccessPreview

type ToolAccessPreview struct {
	ToolName       string   `json:"tool_name"`
	Environment    string   `json:"environment,omitempty"`
	Access         string   `json:"access,omitempty"`
	Command        string   `json:"command,omitempty"`
	Paths          []string `json:"paths,omitempty"`
	NetworkTargets []string `json:"network_targets,omitempty"`
	Justification  string   `json:"justification,omitempty"`
}

ToolAccessPreview is the normalized human-facing summary of what a tool is asking to do.

func BuildPreview

func BuildPreview(req PermissionRequest) ToolAccessPreview

BuildPreview converts a permission request into a stable preview payload.

type ToolPermissionOptions

type ToolPermissionOptions struct {
	ToolInput              map[string]any
	ToolUseID              string
	SessionID              types.SessionID
	TurnID                 types.TurnID
	PermissionMode         types.PermissionMode
	WorkingDirectory       string
	IsToolRunningInSandbox bool
	Stage                  types.ToolPermissionStage
	Intent                 types.ToolPermissionIntent
	Metadata               map[string]any
}

ToolPermissionOptions carries runtime-specific fields for the shared permission pipeline request.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL