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
14 changes: 14 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ type SandboxConfig struct {
ProjectRoot string `json:"project_root,omitempty"`
DenyCommands []string `json:"deny_commands,omitempty"`
AllowedPaths []string `json:"allowed_paths,omitempty"`

// RequireHardBoundary refuses file operations that would be enforced by
// in-process checks alone. It is for a deployment that must not run with a
// weaker boundary than the kernel provides, and it fails closed: on a host
// with no kernel mechanism every fenced operation is refused instead of
// being silently allowed.
RequireHardBoundary bool `json:"require_hard_boundary,omitempty"`
}

// MCPServerConfig defines a single MCP server to connect to.
Expand Down Expand Up @@ -238,6 +245,13 @@ func merge(dst, src Config) Config {
if len(src.Sandbox.AllowedPaths) > 0 {
dst.Sandbox.AllowedPaths = append(dst.Sandbox.AllowedPaths, src.Sandbox.AllowedPaths...)
}
// A bool has no "unset" to distinguish from false, so it is only
// inherited when the overlay turns it on: a project file cannot turn
// off a user-level requirement, and a user file cannot turn it on by
// being absent.
if src.Sandbox.RequireHardBoundary {
dst.Sandbox.RequireHardBoundary = true
}
}

// Merge agent overrides
Expand Down
24 changes: 24 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ func newRootCmd() *cobra.Command {
tool.DefaultSandbox.DenyCommands = append(
tool.DefaultSandbox.DenyCommands, cfg.Sandbox.DenyCommands...)
}
applySandboxCapabilityPolicy(&cfg)

reg := agent.NewRegistry()
if err := applyAgentOverrides(reg, &cfg); err != nil {
Expand Down Expand Up @@ -424,10 +425,12 @@ func newRootCmd() *cobra.Command {
tool.DefaultSandbox.AllowAlways(p)
}
}
applySandboxCapabilityPolicy(&cfg)

// The session is created last: an informational command must never
// create or flush a session file.
sess := store.Create("default")
sess.Containment = tool.ContainmentInfo().String()
ag.SessionStore = sess
defer sess.Flush()

Expand Down Expand Up @@ -482,6 +485,27 @@ func newRootCmd() *cobra.Command {
return rootCmd
}

// applySandboxCapabilityPolicy turns the configured hard-boundary requirement
// into the tool package's policy, and records once what this host can enforce.
//
// The verdict is logged rather than assumed: a deployment that relies on the
// kernel boundary should see, in the run's own log, whether it got one. When
// the requirement is on and no kernel mechanism exists, the warning says what
// will happen next — every fenced operation refuses — so a refusal later in the
// run is not a surprise.
func applySandboxCapabilityPolicy(cfg *config.Config) {
if cfg.Sandbox != nil && cfg.Sandbox.RequireHardBoundary {
tool.SetRequireHardBoundary(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")
}
}

// expandPath expands $VARS and a leading "~" in a configured path, so values
// like "~/.tinycode/sessions" behave the way users expect.
func expandPath(p string) string {
Expand Down
8 changes: 8 additions & 0 deletions session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ type Session struct {
// Permission paths allowed for this session (Allow session)
AllowedPaths []string `json:"allowed_paths,omitempty"`

// Containment records what enforced file boundaries when this session ran,
// as reported by the tool package at creation ("kernel (…)",
// "userspace (…)"). It is recorded once because it is a property of the
// host, not of a call: a later reader can tell how the session's file
// operations were enforced instead of having to infer it from the platform
// the file happens to be read on.
Containment string `json:"containment,omitempty"`

Messages []types.Message `json:"messages"`
dir string
}
Expand Down
31 changes: 31 additions & 0 deletions session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,37 @@ func TestAppendAndFlush(t *testing.T) {
}
}

// TestContainmentRoundTrips pins the field a later reader uses to tell how a
// session's file operations were enforced. It is asserted twice: through the
// struct (so the field survives Flush/Load) and in the raw file (so the key is
// stable for anyone reading the JSON rather than the Go type).
func TestContainmentRoundTrips(t *testing.T) {
dir := t.TempDir()
const enforcement = "kernel (openat2 RESOLVE_BENEATH)"

s := New("containment-roundtrip", dir)
s.Containment = enforcement
if err := s.Flush(); err != nil {
t.Fatal(err)
}

loaded, err := Load("containment-roundtrip", dir)
if err != nil {
t.Fatalf("Load error: %v", err)
}
if loaded.Containment != enforcement {
t.Fatalf("Containment = %q after Load, want %q", loaded.Containment, enforcement)
}

raw, err := os.ReadFile(filepath.Join(dir, "containment-roundtrip.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"containment"`) || !strings.Contains(string(raw), enforcement) {
t.Fatalf("persisted file does not carry the containment fact under its documented key:\n%s", raw)
}
}

func TestLoad(t *testing.T) {
dir := t.TempDir()
s := New("load-test", dir)
Expand Down
2 changes: 1 addition & 1 deletion tool/apply_patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ func ApplyPatch() Tool {

// Build summary
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Applied patch: %d operation(s)\n", len(results)))
sb.WriteString(fmt.Sprintf("Applied patch: %d operation(s)%s\n", len(results), containmentNote()))
for _, r := range results {
switch r.applied {
case "updated":
Expand Down
131 changes: 131 additions & 0 deletions tool/containment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package tool

import (
"errors"
"sync"
"sync/atomic"
)

// ContainmentLevel says what actually enforces a file operation's boundary.
type ContainmentLevel string

const (
// ContainmentKernel means the operating system enforces the boundary as
// part of reaching the path: a component that leaves the root, or a
// component swapped for a symlink between the check and the open, is
// refused by the kernel rather than followed.
ContainmentKernel ContainmentLevel = "kernel"

// ContainmentUserspace means only the in-process checks apply —
// canonicalize, then compare. They answer the question they were written
// for, but they cannot promise anything about a component swapped after
// they ran, because no kernel decision is attached to the open.
ContainmentUserspace ContainmentLevel = "userspace"
)

// The mechanisms behind those levels, named for a report a person reads.
const (
mechanismOpenat2 = "openat2 RESOLVE_BENEATH"
mechanismOpenatWalk = "openat walk, O_NOFOLLOW per component"
mechanismPortable = "resolved-path checks in process"
)

// Containment is what this host can enforce for a file operation.
type Containment struct {
Level ContainmentLevel
Mechanism string
}

// String renders the pair for a log line or a status report.
func (c Containment) String() string {
switch {
case c.Mechanism == "":
return string(c.Level)
case c.Level == "":
return c.Mechanism
default:
return string(c.Level) + " (" + c.Mechanism + ")"
}
}

// probeContainment is the per-platform probe. It is a variable only so this
// package's tests can present a host without a kernel mechanism; production
// never reassigns it.
var probeContainment = defaultProbeContainment

var (
containmentOnce sync.Once
containmentInfo Containment
)

// ContainmentInfo reports what confines a file operation on this host, probing
// once per process.
//
// The probe asks the kernel rather than inferring from the platform name, so
// the verdict cannot drift from what the open layer will actually do: a kernel
// that lost openat2, or a build without the walk, reports the weaker level.
func ContainmentInfo() Containment {
containmentOnce.Do(func() {
containmentInfo = probeContainment()
})
return containmentInfo
}

// resetContainmentForTest installs a probe and clears the once, returning a
// restore function that leaves the real probe to run again on next use.
func resetContainmentForTest(probe func() Containment) func() {
previous := probeContainment
probeContainment = probe
containmentInfo = Containment{}
containmentOnce = sync.Once{}
ContainmentInfo()
return func() {
probeContainment = previous
containmentInfo = Containment{}
containmentOnce = sync.Once{}
}
}

var requireHardBoundary atomic.Bool

// SetRequireHardBoundary turns the "require a hard boundary" policy on or off.
// It is configuration, not a per-call decision: the host either has a kernel
// mechanism or it does not.
func SetRequireHardBoundary(required bool) { requireHardBoundary.Store(required) }

// HardBoundaryRequired reports whether that policy is on.
func HardBoundaryRequired() bool { return requireHardBoundary.Load() }

// ErrHardBoundaryUnavailable is returned when the policy demands a kernel
// boundary and the host has none.
//
// It is an error rather than a permission prompt on purpose: no approval can
// create a capability the host does not have, so refusing is the only honest
// answer.
var ErrHardBoundaryUnavailable = errors.New(
"a hard file boundary is required (sandbox.require_hard_boundary) but this host enforces file operations with in-process checks only; refusing rather than running with a weaker boundary")

// checkHardBoundary applies the policy to one operation.
func checkHardBoundary() error {
if !HardBoundaryRequired() {
return nil
}
if ContainmentInfo().Level == ContainmentKernel {
return nil
}
return ErrHardBoundaryUnavailable
}

// containmentNote is appended to a file-mutating tool's success result only
// when the kernel is NOT enforcing the boundary, so a degraded host is visible
// in the result the model is reading rather than only in a log line.
//
// It is empty wherever a kernel mechanism exists, which is every supported
// platform with openat2 or the component walk; ordinary results are therefore
// unchanged, and the note cannot become noise that readers learn to skip.
func containmentNote() string {
if ContainmentInfo().Level == ContainmentKernel {
return ""
}
return " [containment: in-process checks only — this host has no kernel file-boundary mechanism]"
}
15 changes: 15 additions & 0 deletions tool/containment_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build darwin

package tool

// defaultProbeContainment reports the mechanism the open layer uses on macOS.
//
// There is no openat2 here, so every open goes through the component walk: each
// component is opened with O_NOFOLLOW, and a component that is a symlink — which
// a resolved path cannot legitimately contain, and which is therefore one
// swapped in after the check — is refused with ELOOP rather than followed. That
// is a kernel decision attached to the open, so the level is kernel; what macOS
// lacks is the single-syscall form, not the boundary.
func defaultProbeContainment() Containment {
return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenatWalk}
}
50 changes: 50 additions & 0 deletions tool/containment_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//go:build linux

package tool

import (
"errors"

"golang.org/x/sys/unix"
)

// defaultProbeContainment asks the kernel which mechanism it will actually
// apply to an open, in the same order the open layer tries them.
//
// openat2 with RESOLVE_BENEATH is the strongest: the decision and the open are
// one syscall. Where it is missing, the component walk (openat with
// O_NOFOLLOW on every component) still confines — a symlink swapped in after
// the check is refused instead of followed — so the level stays kernel. Only a
// host with neither falls back to in-process checks.
func defaultProbeContainment() Containment {
dir, err := unix.Open(".", unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
// Nothing to probe against. Report the weaker level rather than
// claiming a boundary that was not demonstrated.
return Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable}
}
defer unix.Close(dir)

how := &unix.OpenHow{
Flags: unix.O_PATH | unix.O_CLOEXEC,
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_MAGICLINKS,
}
fd, err := unix.Openat2(dir, ".", how)
if err == nil {
unix.Close(fd)
return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenat2}
}

// The same three errno values the escape probe treats as "this kernel does
// not have openat2"; latch it so the runtime stops trying.
if errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.E2BIG) {
openat2Unsupported.Store(true)
// The walk needs only openat, which predates openat2 by a decade: a
// kernel that lacks openat2 still confines through it.
return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenatWalk}
}

// Any other error is about this particular path, not about the mechanism.
// openat2 is present, so the boundary is the kernel's.
return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenat2}
}
11 changes: 11 additions & 0 deletions tool/containment_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !linux && !darwin

package tool

// defaultProbeContainment reports the weakest level for platforms where
// openResolvedNoFollow has no implementation: there is no kernel-side walk to
// attach the decision to, so the in-process checks in CheckPath are the only
// gate.
func defaultProbeContainment() Containment {
return Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable}
}
Loading
Loading