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
11 changes: 7 additions & 4 deletions apps/daemon/internal/agent/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime, environmen
// Environment none. A request with DisableExecutionEnvironment runs in an
// empty-root view: a read-only, noexec tmpfs root that holds only the
// mountpoints for the closure, the home, the agent host's runtime files,
// ViewProcRoot, ViewDevRoot and the overlays. It has no world, no shims, no
// ViewProcRoot, ViewSysRoot, ViewDevRoot and the overlays. It has no world, no shims, no
// Link attachment and no sandbox network, so the generic proxy refuses every
// request; the cgroup, the isolation and the gateway stay. The Harness runs in
// ViewPrivateRoot/ViewHomeName/ViewWorkName. A request with neither
Expand Down Expand Up @@ -152,8 +152,11 @@ const (
// ViewWorkName is the working directory under the home in an empty-root
// view.
ViewWorkName = "work"
// ViewProcRoot and ViewDevRoot are the view's own /proc and minimal /dev.
// ViewProcRoot, ViewSysRoot and ViewDevRoot are the view's own kernel
// filesystems and minimal /dev. The read-only /sys/fs/cgroup exposes only
// the view's cgroup subtree, rooted in its cgroup namespace.
ViewProcRoot = "/proc"
ViewSysRoot = "/sys"
ViewDevRoot = "/dev"
)

Expand All @@ -167,9 +170,9 @@ func ViewAlias(i int) string {
}

// ViewReserved reports whether the view path p is at or beneath a tree the
// view builds itself: ViewPrivateRoot, ViewProcRoot or ViewDevRoot.
// view builds itself: ViewPrivateRoot, ViewProcRoot, ViewSysRoot or ViewDevRoot.
func ViewReserved(p string) bool {
for _, root := range [...]string{ViewPrivateRoot, ViewProcRoot, ViewDevRoot} {
for _, root := range [...]string{ViewPrivateRoot, ViewProcRoot, ViewSysRoot, ViewDevRoot} {
if p == root || isWithin(p, root) {
return true
}
Expand Down
3 changes: 3 additions & 0 deletions apps/daemon/internal/agent/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ func TestViewValidate(t *testing.T) {
"shim path equal to a mask": func(v *agent.View) { v.ShimPaths = append(v.ShimPaths, "/etc/harness") },
"overlay in the private root": func(v *agent.View) { v.Overlays[1].Path = "/.oac/certs" },
"mask in /proc": func(v *agent.View) { v.Masks[0].Path = "/proc/cpuinfo" },
"overlay in /sys": func(v *agent.View) { v.Overlays[1].Path = "/sys/fs/cgroup" },
"mask in /sys": func(v *agent.View) { v.Masks[0].Path = "/sys/devices" },
"shim in /sys": func(v *agent.View) { v.ShimPaths = append(v.ShimPaths, "/sys/tool") },
"unclean view path": func(v *agent.View) { v.Masks[0].Path = "/etc/../etc/harness" },
"duplicate shim": func(v *agent.View) { v.Shims = append(v.Shims, "git") },
"shim named as the relay": func(v *agent.View) { v.Shims = append(v.Shims, agent.ViewRelayName) },
Expand Down
3 changes: 2 additions & 1 deletion apps/daemon/internal/agenthost/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@
// Session home read-write and noexec, the agent host's /etc/passwd, group,
// hosts, resolv.conf and nsswitch.conf, the agent host's CA directory at its
// host path, then the adapter's overlays and masks and the process shim with
// its relay. Everything else is the world, or nothing in an empty-root view.
// its relay. The view owns /proc, /sys and /dev. Everything else is the
// world, or nothing in an empty-root view.
//
// The Session directory, StateDir/sessions/<Session ID>, stays root-owned
// and private. Its home holds the Harness's native history and persists
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agenthost/environment_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ func TestEnvironmentOwnerKeepsWorkspaceAcrossRouters(t *testing.T) {
t.Fatalf("owner lost at epoch %d", epoch)
}
}
for _, workspace := range []string{"", "relative", "/projects/../two", "C:/project", "/", "/.oac", "/.oac/home", "/proc/1", "/dev/shm", "/etc", "/etc/passwd", "/trust", "/trust/roots"} {
for _, workspace := range []string{"", "relative", "/projects/../two", "C:/project", "/", "/.oac", "/.oac/home", "/proc/1", "/sys", "/sys/project", "/dev/shm", "/etc", "/etc/passwd", "/trust", "/trust/roots"} {
fresh := newBinding(newResource())
invalid := bindPayload(fresh)
invalid.WorkspaceDirectory = workspace
Expand Down
21 changes: 21 additions & 0 deletions apps/daemon/internal/sessionview/build_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@ func (b *builder) build(spec *launchSpec) error {
if err := b.attach(b.proc, b.root, at, true); err != nil {
return err
}
at, err = b.at(agent.ViewSysRoot)
if err != nil {
return err
}
sys, err := newFS("sysfs", nil, unix.MOUNT_ATTR_RDONLY|attrNoSuid|attrNoDev|attrNoExec)
if err != nil {
return err
}
defer unix.Close(sys)
if err := b.attach(sys, b.root, at, true); err != nil {
return err
}
// Mount in the view's cgroup namespace, never bind the host hierarchy.
cgroup, err := newFS("cgroup2", nil, unix.MOUNT_ATTR_RDONLY|attrNoSuid|attrNoDev|attrNoExec)
if err != nil {
return err
}
defer unix.Close(cgroup)
if err := b.attachAt(cgroup, sys, "fs/cgroup", agent.ViewSysRoot+"/fs/cgroup", true); err != nil {
return err
}
return b.dev()
}

Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/sessionview/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package sessionview runs a process, and others spawned beside it, inside a per-Session view on the agent host.
//
// A view is a private mount, PID and network namespace whose root is the Session's world: a FUSE file system that the daemon serves over a /dev/fuse connection. The launcher adds the local pieces on top of the world: private directories under /.oac, trusted overlays, the command shim, a fresh /proc and a minimal /dev. The world presents a mountpoint for each piece and reports where, following the sandbox's symlinks, and the launcher mounts at those paths without following any symlink itself. A [Spec] without a world gets an empty root instead: a read-only, noexec tmpfs that holds only the mountpoints, each at its own path. The process starts with no capabilities, no_new_privs, a seccomp filter and only stdin, stdout and stderr open. Its network namespace has only loopback up.
// A view is private mount, PID, network and cgroup namespaces whose root is the Session's world: a FUSE file system that the daemon serves over a /dev/fuse connection. The launcher adds the local pieces on top of the world: private directories under /.oac, trusted overlays, the command shim, fresh /proc and read-only /sys filesystems, a read-only cgroup2 mount rooted at the view and a minimal /dev. The world presents a mountpoint for each piece and reports where, following the sandbox's symlinks, and the launcher mounts at those paths without following any symlink itself. A [Spec] without a world gets an empty root instead: a read-only, noexec tmpfs that holds only the mountpoints, each at its own path. The process starts with no capabilities, no_new_privs, a seccomp filter and only stdin, stdout and stderr open. Its network namespace has only loopback up.
//
// With a world, the initial process starts only in a directory on that world: the launcher resolves [Process].Dir without symlinks or crossing a mount, then enters the pinned directory before starting the process. Private directories and overlays cannot become its workspace through aliases. An empty-root view and later [View.Spawn] commands retain ordinary working-directory resolution.
//
Expand Down
8 changes: 7 additions & 1 deletion apps/daemon/internal/sessionview/view_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ func (v *View) launch(spec *Spec) error {
if v.cgroup, err = os.MkdirTemp(spec.CgroupParent, "view-*"); err != nil {
return &Error{Kind: ErrCgroup, Op: "create", Path: spec.CgroupParent, Err: err}
}
// The view reads its own kernel accounting through a read-only mount.
// Keep root ownership and grant no cgroup management permissions.
if err := os.Chmod(v.cgroup, 0o555); err != nil {
return &Error{Kind: ErrCgroup, Op: "chmod", Path: v.cgroup, Err: err}
}
cgroup, err := os.Open(v.cgroup)
if err != nil {
return &Error{Kind: ErrCgroup, Op: "open", Path: v.cgroup, Err: err}
Expand Down Expand Up @@ -164,7 +169,7 @@ func (v *View) launch(spec *Spec) error {
ExtraFiles: files,
// Cloning into the namespaces, rather than unsharing later, puts every runtime thread of the launcher in them and makes it PID 1 of the view. Cloning into the cgroup, rather than moving the launcher there, means that no process of the view ever runs outside it.
SysProcAttr: &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWPID,
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWPID | syscall.CLONE_NEWCGROUP,
Setsid: true,
UseCgroupFD: true,
CgroupFD: int(cgroup.Fd()),
Expand Down Expand Up @@ -703,6 +708,7 @@ func (s *Spec) mountpoints() []Mountpoint {
}
m = append(m,
Mountpoint{Path: agent.ViewProcRoot, Dir: true},
Mountpoint{Path: agent.ViewSysRoot, Dir: true},
Mountpoint{Path: agent.ViewDevRoot, Dir: true},
)
for _, o := range s.Overlays {
Expand Down
63 changes: 61 additions & 2 deletions apps/daemon/internal/sessionview/view_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ func TestMain(m *testing.M) {
func TestViewIsolation(t *testing.T) {
requireView(t)
f := newFixture(t)
sibling := filepath.Join(f.cgroups, "other-view")
if err := unix.Mkdir(sibling, 0o755); err != nil {
t.Fatal(err)
}
defer unix.Rmdir(sibling)
w := &loopbackWorld{dir: f.world}
spec := f.spec(w, "probe", "OAC_VIEW_HOST_PATH="+f.self)
spec.Network.Setup = serveBroker(t)
Expand Down Expand Up @@ -803,6 +808,7 @@ func TestWorldInitialDirectoryStaysInWorld(t *testing.T) {
{"private mount", "/.oac/home", unix.EXDEV},
{"overlay mount", "/etc/oac-overlay", unix.EXDEV},
{"proc mount", "/proc", unix.EXDEV},
{"sys mount", "/sys", unix.EXDEV},
{"custom workspace", "/data/project", nil},
{"world root", "/", nil},
} {
Expand Down Expand Up @@ -920,7 +926,7 @@ func newFixture(t *testing.T) *fixture {
staging: filepath.Join(base, "staging"),
cgroups: sessionviewtest.CgroupParent(t),
}
for _, d := range []string{".oac/harness", ".oac/home", ".oac/run", ".oac/bin", "proc", "dev", "bin", "usr/bin", "data", "etc/oac-overlay"} {
for _, d := range []string{".oac/harness", ".oac/home", ".oac/run", ".oac/bin", "proc", "sys", "dev", "bin", "usr/bin", "data", "etc/oac-overlay"} {
mkdir(t, filepath.Join(f.world, d))
}
writeFile(t, filepath.Join(f.world, "bin", "sh"), "")
Expand Down Expand Up @@ -1363,7 +1369,11 @@ func runHelper(mode string) int {
if err := unix.Statfs("/", &st); err != nil || st.Type != unix.TMPFS_MAGIC || st.Flags&(unix.ST_RDONLY|unix.ST_NOEXEC) != unix.ST_RDONLY|unix.ST_NOEXEC {
errs = append(errs, fmt.Errorf("root: type %#x flags %#x, %v", st.Type, st.Flags, err))
}
for dir, want := range map[string][]string{"/": {".oac", "dev", "etc", "proc"}, "/.oac": {"bin", "harness", "home"}, "/.oac/bin": nil, "/etc": {"oac-overlay"}} {
if err := systemFilesystems(); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
for dir, want := range map[string][]string{"/": {".oac", "dev", "etc", "proc", "sys"}, "/.oac": {"bin", "harness", "home"}, "/.oac/bin": nil, "/etc": {"oac-overlay"}} {
entries, err := os.ReadDir(dir)
var names []string
for _, e := range entries {
Expand Down Expand Up @@ -1516,6 +1526,7 @@ var viewChecks = []struct {
}
return nil
}},
{"system filesystems belong to the view", systemFilesystems},
{"the relay runs as the view user without privileges", func() error {
pid := relayPID()
if pid == 0 {
Expand Down Expand Up @@ -1585,6 +1596,54 @@ func relayPID() int {
return 0
}

func systemFilesystems() error {
for path, kind := range map[string]int64{"/sys": unix.SYSFS_MAGIC, "/sys/fs/cgroup": unix.CGROUP2_SUPER_MAGIC} {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
return err
}
if stat.Type != kind || stat.Flags&unix.ST_RDONLY == 0 || stat.Flags&unix.ST_NOEXEC == 0 {
return fmt.Errorf("%s is not the read-only, noexec kernel filesystem: type=%x flags=%x", path, stat.Type, stat.Flags)
}
}
if err := fileHas("/proc/self/cgroup", "0::/\n"); err != nil {
return err
}
procs, err := os.ReadFile("/sys/fs/cgroup/cgroup.procs")
if err != nil || !slices.Contains(strings.Fields(string(procs)), strconv.Itoa(os.Getpid())) {
return fmt.Errorf("current cgroup omits self: %q, %v", procs, err)
}
entries, err := os.ReadDir("/sys/fs/cgroup")
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
return fmt.Errorf("another cgroup is visible: %s", entry.Name())
}
}
devices, err := os.ReadDir("/sys/class/net")
if err != nil {
return err
}
for _, device := range devices {
if device.Type()&os.ModeSymlink != 0 && device.Name() != "lo" {
return fmt.Errorf("another network namespace's device is visible: %s", device.Name())
}
}
for _, path := range []string{"/sys/fs/cgroup/cgroup.procs", "/sys/devices/system/cpu/online"} {
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if f != nil {
f.Close()
return fmt.Errorf("system file is writable: %s", path)
}
if !errors.Is(err, syscall.EROFS) && !errors.Is(err, syscall.EACCES) {
return fmt.Errorf("write system file %s: %v", path, err)
}
}
return nil
}

func onlyStdio() error {
entries, err := os.ReadDir("/proc/self/fd")
if err != nil {
Expand Down
6 changes: 4 additions & 2 deletions contracts/agents-api/harness-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,20 +294,22 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v

- view and host paths are absolute and clean;
- closure names are single path components other than `bin`, `home` and `run`, which the agent host uses for the shims, the Session home and the process relay;
- shim paths, overlays and masks do not overlap each other or `/`, and stay out of the trees the view builds itself: `/.oac`, `/proc` and `/dev` (`ViewReserved`);
- shim paths, overlays and masks do not overlap each other or `/`, and stay out of the trees the view builds itself: `/.oac`, `/proc`, `/sys` and `/dev` (`ViewReserved`);
- each `LocalExec` entry lies in a closure directory or an `Exec` overlay;
- shim names and `ForwardEnv` names are unique, no shim is named `oac-process-shim`, which is the process relay's, or starts with `oac-mcp-`, which [stdio aliases](#stdio-mcp) use, a variable name contains no `=`, and `ForwardEnv` names no variable the view or the broker sets ([Environment](#environment));
- `Proxy` is one of the two values and `Executor` is non-nil.

`harness.go` defines the view layout once, and `sessionview` builds views from it. The agent host checks its own overlays, such as `/etc/passwd`, against the declaration when it builds the view. A workspace must remain entirely in the sandbox world: binding rejects overlap with the common reserved trees or agent-host overlays before any Environment effect, and Harness admission rejects overlap with its declared overlays, masks or shim paths before any native effect. Neither operation substitutes a private home or another directory. At initial launch with a sandbox world, the launcher opens the working directory beneath that world without following symlinks or crossing mounts, then enters the opened directory before forking; changing the path cannot redirect startup into a view-owned mount. An empty-root launch and `Spawn` retain their own directory rules, including native-history helpers in the private home. This startup check does not restrict where the native process may later change directory.

The view owns the native process's kernel interfaces: a fresh `/proc`, read-only `/sys` and minimal `/dev`. A fresh read-only cgroup2 mount at `/sys/fs/cgroup` is rooted in the view's cgroup namespace, so `/proc/self/cgroup` and the visible hierarchy describe the same process group without exposing ancestor or sibling cgroups. The view mounts sysfs in its own network namespace; it never binds the host's `/sys` tree. Native file tools see these reserved kernel paths locally. Commands forwarded through the Process protocol and public File operations continue to use the sandbox's filesystem, including its `/sys`; workspace files remain in the sandbox world. These mounts also exist in an empty-root view.

### Capabilities

A view runs every request that the kind's declaration admits, so the adapter declares only what its view runs, and dispatch checks each request against that declaration. The agent host serves a local Environment and environment none, and every view runs the Environment's installed Skills and [stdio MCP](#stdio-mcp). The Environment owner fills `PrepareRequest.Skills` and `CapabilityRoot` as sandbox paths, and the adapter hands them to its Harness; only the Harness reads them, through the view, and the adapter opens none of them on the agent host. The agent host rejects a stdio binding that needs a credential with `ErrViewHandoff`.

### Environment none

A request with `DisableExecutionEnvironment` runs in an empty-root view: a read-only, noexec tmpfs at `/` that holds only the mountpoints for the closure, the Session home, the agent host's runtime files, `/proc`, `/dev` and the overlays. It has no sandbox files, no shims, no Link attachment and no sandbox network, so the generic proxy refuses every request; the cgroup, the isolation and the gateway stay. The request carries no `LocalEnvironment`, and the Harness runs in `/.oac/home/work` (`ViewWorkName`). The request already expresses the profile, so the wire has no field for it. A request with neither `LocalEnvironment` nor `DisableExecutionEnvironment` is an incomplete binding, and the agent host rejects it.
A request with `DisableExecutionEnvironment` runs in an empty-root view: a read-only, noexec tmpfs at `/` that holds only the mountpoints for the closure, the Session home, the agent host's runtime files, `/proc`, `/sys`, `/dev` and the overlays. It has no sandbox files, no shims, no Link attachment and no sandbox network, so the generic proxy refuses every request; the cgroup, the isolation and the gateway stay. The request carries no `LocalEnvironment`, and the Harness runs in `/.oac/home/work` (`ViewWorkName`). The request already expresses the profile, so the wire has no field for it. A request with neither `LocalEnvironment` nor `DisableExecutionEnvironment` is an incomplete binding, and the agent host rejects it.

### Executables

Expand Down
Loading
Loading