replace landrun with native Landlock sandbox
This commit is contained in:
parent
09729a9149
commit
bdafc30022
|
|
@ -97,7 +97,7 @@ For direct Go testing, use the packages covered by `make test-core` and `make te
|
|||
3. **Agent loop** (`cmd/olliesrv/internal/agent/loop.go` and `turn.go`): Stream an LLM response, parse native or text tool calls, execute tools, update history, and repeat until a final response, cancellation, or a configured limit. It includes transient retries, context-overflow compaction, error/stall/replan controls, and tool-result caching.
|
||||
4. **Dynamic tools**: Tools are external executables described by `.meta` files. `toolsrv` owns discovery and per-agent registries. `olliesrv` refreshes the registry, injects common dispatch flags (`bypass`, `timeout`, `sandbox`, `background`), and calls tools through `toolsrv.Conn`.
|
||||
5. **Parallel and background dispatch**: Non-conflicting tool calls in one turn run concurrently. Metadata scopes schedule `read`, `write`, and global operations; shell-like global operations serialize. Any tool may run in the background, producing a process ID whose output is injected when the process changes or exits.
|
||||
6. **Sandbox** (`cmd/toolsrv/internal/sandbox/`): Landlock/landrun policies control filesystem and network access. The installed `sandbox.yaml` is sourced from `cmd/toolsrv/internal/sandbox/sandbox.yaml`. The toolsrv namespace and process state live under `cmd/toolsrv/p9.go` and `cmd/toolsrv/internal/server/`. The bypass broker provides policy-controlled escape requests, approval, persistence, and rate limiting.
|
||||
6. **Sandbox** (`cmd/toolsrv/internal/sandbox/`): Native Landlock policies control filesystem access in a short-lived child helper. The installed `sandbox.yaml` is sourced from `cmd/toolsrv/internal/sandbox/sandbox.yaml`. The toolsrv namespace and process state live under `cmd/toolsrv/p9.go` and `cmd/toolsrv/internal/server/`. The bypass broker provides policy-controlled escape requests, approval, persistence, and rate limiting.
|
||||
7. **Backends** (`cmd/olliesrv/internal/backend/`): Supported names are `ollama`, `openai`, `openrouter`, `anthropic`, `copilot`, `kiro`, and `gemini`. Configuration is read from `~/.config/ollie/backends.conf`; environment variables are fallback inputs.
|
||||
8. **Prompt assembly**: `cmd/olliesrv/internal/prompts/system_prompt.md` is embedded as the default system prompt. An agent's `systemPrompt` can override it with a filesystem path. `prompt` and `userPrompts` entries resolve files, expand environment variables, and support legacy `!command` entries. The runtime combines system, environment, agent, and tool sections.
|
||||
9. **Context management**: History tracks messages, usage, costs, cache statistics, and structured task state. Cold/warm/hot result tiers and automatic compaction preserve recent context while summarizing older material.
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ session/
|
|||
|
||||
Provider backends are the model-facing boundary; the agent loop is independent of vendor APIs. Tools are external executable programs served by the separate `toolsrv` 9P process. Their metadata is discovered and loaded lazily, so the model sees only the capabilities needed for a task.
|
||||
|
||||
Tool execution uses the configured Landlock/landrun sandbox. Operations requiring an approved escape use the bypass namespace and broker, which applies policy and approval controls rather than providing an unrestricted escape.
|
||||
Tool execution uses the configured native Landlock sandbox. Operations requiring an approved escape use the bypass namespace and broker, which applies policy and approval controls rather than providing an unrestricted escape.
|
||||
|
||||
Persistent memory is provided by [OptMem](https://github.com/VictorTaelin/OptMem) through `memory_recall` and `memory_remember`; Ollie does not maintain a second memory-file format. History management renders context, tracks usage and cost, caps oversized tool output, and compacts older material when necessary.
|
||||
|
||||
|
|
|
|||
|
|
@ -167,8 +167,9 @@ type RemoteConfig struct {
|
|||
}
|
||||
|
||||
// SpawnRemote starts a remote toolsrv process via SSH.
|
||||
// Deploys the local ~/.config/ollie directory (including tools/*.meta) to the
|
||||
// remote host, then starts toolsrv with SSH Unix socket forwarding.
|
||||
// Deploys the local configuration directory (including the toolsrv binary and tools/*.meta)
|
||||
// to the remote host, then starts toolsrv with SSH Unix socket forwarding. Sandboxing
|
||||
// is implemented by the transferred toolsrv binary; no external sandbox executable is copied.
|
||||
func SpawnRemote(ctx context.Context, cfg RemoteConfig) (*Process, error) {
|
||||
// Verify local config dir exists
|
||||
cfgDir := util.CfgDir()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import (
|
|||
const serviceName = "ollie"
|
||||
|
||||
var tcpAddr = flag.String("tcp", "", "also listen on TCP address (e.g. :564)")
|
||||
var yolo = flag.Bool("yolo", false, "skip landrun sandbox for shell")
|
||||
var yolo = flag.Bool("yolo", false, "skip native Landlock sandbox for shell")
|
||||
|
||||
func main() {
|
||||
flag.CommandLine.Parse(os.Args[1:]) //nolint:errcheck
|
||||
|
|
|
|||
|
|
@ -171,13 +171,12 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE
|
|||
envMap["NAMESPACE"] = ns
|
||||
}
|
||||
}
|
||||
getenv := func(key string) string { return envMap[key] }
|
||||
|
||||
var cmd *osExec.Cmd
|
||||
if yolo {
|
||||
cmd = osExec.CommandContext(ctx, interpreter[0], interpreter[1:]...)
|
||||
} else {
|
||||
wrapped, wrapErr := sandbox.WrapCommand(sandboxCfg, interpreter, cwd, getenv)
|
||||
wrapped, wrapErr := sandbox.NativeCommand(sandboxCfg, interpreter, cwd, envMap)
|
||||
if wrapErr != nil {
|
||||
return "", wrapErr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ type NetworkConfig struct {
|
|||
ConnectTCP []string `yaml:"connect_tcp"`
|
||||
}
|
||||
|
||||
// AdvancedConfig contains advanced landrun settings
|
||||
// AdvancedConfig contains compatibility settings retained in the policy format.
|
||||
type AdvancedConfig struct {
|
||||
LDD bool `yaml:"ldd"`
|
||||
AddExec bool `yaml:"add_exec"`
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
package sandbox
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
available bool
|
||||
availableOnce sync.Once
|
||||
)
|
||||
|
||||
// isAvailable checks if landrun is available on the system.
|
||||
// The result is cached after the first call for performance.
|
||||
func isAvailable() bool {
|
||||
availableOnce.Do(func() {
|
||||
_, err := exec.LookPath("landrun")
|
||||
available = (err == nil)
|
||||
})
|
||||
return available
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type helperPolicy struct {
|
||||
Config Config `json:"config"`
|
||||
CWD string `json:"cwd"`
|
||||
Env map[string]string `json:"env"`
|
||||
Command []string `json:"command"`
|
||||
}
|
||||
|
||||
func ExecHelper(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("sandbox-exec: expected encoded policy")
|
||||
}
|
||||
data, err := base64.RawStdEncoding.DecodeString(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var p helperPolicy
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := restrict(&p.Config, p.CWD, p.Env); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(p.Command) == 0 {
|
||||
return fmt.Errorf("sandbox-exec: empty command")
|
||||
}
|
||||
if err := syscall.Chdir(p.CWD); err != nil {
|
||||
return err
|
||||
}
|
||||
return syscall.Exec(p.Command[0], p.Command, envList(p.Env))
|
||||
}
|
||||
|
||||
func envList(env map[string]string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for k, v := range env {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func restrict(cfg *Config, cwd string, env map[string]string) error {
|
||||
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("set no_new_privs: %w", err)
|
||||
}
|
||||
fsAccess := uint64(unix.LANDLOCK_ACCESS_FS_EXECUTE | unix.LANDLOCK_ACCESS_FS_WRITE_FILE | unix.LANDLOCK_ACCESS_FS_READ_FILE | unix.LANDLOCK_ACCESS_FS_READ_DIR | unix.LANDLOCK_ACCESS_FS_REMOVE_DIR | unix.LANDLOCK_ACCESS_FS_REMOVE_FILE | unix.LANDLOCK_ACCESS_FS_MAKE_CHAR | unix.LANDLOCK_ACCESS_FS_MAKE_DIR | unix.LANDLOCK_ACCESS_FS_MAKE_REG | unix.LANDLOCK_ACCESS_FS_MAKE_SOCK | unix.LANDLOCK_ACCESS_FS_MAKE_FIFO | unix.LANDLOCK_ACCESS_FS_MAKE_BLOCK | unix.LANDLOCK_ACCESS_FS_MAKE_SYM)
|
||||
fd, _, err := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, uintptr(unsafe.Pointer(&fsAccess)), unsafe.Sizeof(fsAccess), 0)
|
||||
if err != 0 {
|
||||
return fmt.Errorf("landlock unavailable: %w", err)
|
||||
}
|
||||
defer unix.Close(int(fd))
|
||||
add := func(paths []string, access uint64) error {
|
||||
for _, pattern := range paths {
|
||||
p := expandPath(pattern, cwd, func(k string) string { return env[k] })
|
||||
if !pathExists(p) {
|
||||
continue
|
||||
}
|
||||
f, e := unix.Open(p, unix.O_PATH|unix.O_CLOEXEC, 0)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
rule := struct {
|
||||
Allowed uint64
|
||||
ParentFD int
|
||||
}{access, int(f)}
|
||||
_, _, e = unix.Syscall6(unix.SYS_LANDLOCK_ADD_RULE, fd, uintptr(1), uintptr(unsafe.Pointer(&rule)), 0, 0, 0)
|
||||
unix.Close(f)
|
||||
if e != nil {
|
||||
return fmt.Errorf("add landlock path %s: %w", p, e)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
read := uint64(unix.LANDLOCK_ACCESS_FS_READ_FILE | unix.LANDLOCK_ACCESS_FS_READ_DIR)
|
||||
write := uint64(unix.LANDLOCK_ACCESS_FS_WRITE_FILE | unix.LANDLOCK_ACCESS_FS_REMOVE_DIR | unix.LANDLOCK_ACCESS_FS_REMOVE_FILE | unix.LANDLOCK_ACCESS_FS_MAKE_DIR | unix.LANDLOCK_ACCESS_FS_MAKE_REG | unix.LANDLOCK_ACCESS_FS_MAKE_SOCK | unix.LANDLOCK_ACCESS_FS_MAKE_FIFO | unix.LANDLOCK_ACCESS_FS_MAKE_CHAR | unix.LANDLOCK_ACCESS_FS_MAKE_BLOCK | unix.LANDLOCK_ACCESS_FS_MAKE_SYM)
|
||||
if err := add(cfg.Filesystem.RO, read); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := add(cfg.Filesystem.ROX, read|unix.LANDLOCK_ACCESS_FS_EXECUTE); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := add(cfg.Filesystem.RW, read|write); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := add(cfg.Filesystem.RWX, read|write|unix.LANDLOCK_ACCESS_FS_EXECUTE); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = unix.Syscall(unix.SYS_LANDLOCK_RESTRICT_SELF, fd, 0, 0)
|
||||
if err != 0 {
|
||||
return fmt.Errorf("restrict landlock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = os.Args
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
//go:build !linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import "fmt"
|
||||
|
||||
func ExecHelper([]string) error { return fmt.Errorf("native Landlock sandbox requires Linux") }
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Default execute_code sandbox configuration.
|
||||
# native Landlock sandbox configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{XDG_CONFIG_HOME}/git"
|
||||
|
|
|
|||
|
|
@ -1,697 +0,0 @@
|
|||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain creates a fake landrun binary so IsAvailable() returns true for all tests.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "sandbox-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fakeLandrun := filepath.Join(tmpDir, "landrun")
|
||||
if err := os.WriteFile(fakeLandrun, []byte("#!/bin/sh\necho 'landrun 0.0.0'\n"), 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
os.Setenv("PATH", tmpDir+":"+os.Getenv("PATH"))
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// ---- expandPath ----
|
||||
|
||||
func TestExpandPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
cwd string
|
||||
env map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "literal",
|
||||
pattern: "/etc/passwd",
|
||||
cwd: "/cwd",
|
||||
want: "/etc/passwd",
|
||||
},
|
||||
{
|
||||
name: "CWD",
|
||||
pattern: "{CWD}/foo",
|
||||
cwd: "/my/cwd",
|
||||
want: "/my/cwd/foo",
|
||||
},
|
||||
{
|
||||
name: "TMPDIR from env",
|
||||
pattern: "{TMPDIR}",
|
||||
cwd: "/cwd",
|
||||
env: map[string]string{"TMPDIR": "/custom/tmp"},
|
||||
want: "/custom/tmp",
|
||||
},
|
||||
{
|
||||
name: "TMPDIR default",
|
||||
pattern: "{TMPDIR}",
|
||||
cwd: "/cwd",
|
||||
env: map[string]string{"TMPDIR": ""},
|
||||
want: "/tmp",
|
||||
},
|
||||
{
|
||||
name: "custom env var",
|
||||
pattern: "{MY_SANDBOX_TEST_VAR}/bar",
|
||||
cwd: "/cwd",
|
||||
env: map[string]string{"MY_SANDBOX_TEST_VAR": "/custom"},
|
||||
want: "/custom/bar",
|
||||
},
|
||||
{
|
||||
name: "unknown var unchanged",
|
||||
pattern: "{NONEXISTENT_SANDBOX_VAR_XYZ}",
|
||||
cwd: "/cwd",
|
||||
want: "{NONEXISTENT_SANDBOX_VAR_XYZ}",
|
||||
},
|
||||
{
|
||||
name: "XDG_CONFIG_HOME from env",
|
||||
pattern: "{XDG_CONFIG_HOME}/app",
|
||||
cwd: "/cwd",
|
||||
env: map[string]string{"XDG_CONFIG_HOME": "/xdg/config"},
|
||||
want: "/xdg/config/app",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for k, v := range tc.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
got := expandPath(tc.pattern, tc.cwd, os.Getenv)
|
||||
if got != tc.want {
|
||||
t.Errorf("expandPath(%q, %q) = %q, want %q", tc.pattern, tc.cwd, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- WrapCommand ----
|
||||
|
||||
func TestWrapCommand(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rwDir := filepath.Join(tmpDir, "rw")
|
||||
roDir := filepath.Join(tmpDir, "ro")
|
||||
if err := os.MkdirAll(rwDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(roDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("starts with landrun", func(t *testing.T) {
|
||||
cfg := &Config{General: GeneralConfig{}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}}
|
||||
got := mustWrapCommand(t, cfg, []string{"echo", "hi"}, tmpDir)
|
||||
if len(got) == 0 || got[0] != "landrun" {
|
||||
t.Errorf("expected landrun as first arg, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("separator before original command", func(t *testing.T) {
|
||||
cfg := &Config{General: GeneralConfig{}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}}
|
||||
got := mustWrapCommand(t, cfg, []string{"echo", "test"}, tmpDir)
|
||||
sepIdx := indexOf(got, "--")
|
||||
if sepIdx == -1 {
|
||||
t.Fatal("missing -- separator")
|
||||
}
|
||||
if got[sepIdx+1] != "echo" || got[sepIdx+2] != "test" {
|
||||
t.Errorf("expected [echo test] after --, got %v", got[sepIdx+1:])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("log level flag", func(t *testing.T) {
|
||||
cfg := &Config{General: GeneralConfig{LogLevel: "debug"}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertFlagValue(t, got, "--log-level", "debug")
|
||||
})
|
||||
|
||||
t.Run("no log level when empty", func(t *testing.T) {
|
||||
cfg := &Config{General: GeneralConfig{LogLevel: ""}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertDoesNotContain(t, got, "--log-level")
|
||||
})
|
||||
|
||||
t.Run("best effort flag", func(t *testing.T) {
|
||||
cfg := &Config{General: GeneralConfig{BestEffort: true}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertContains(t, got, "--best-effort")
|
||||
})
|
||||
|
||||
t.Run("no best effort when false", func(t *testing.T) {
|
||||
cfg := &Config{General: GeneralConfig{BestEffort: false}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertDoesNotContain(t, got, "--best-effort")
|
||||
})
|
||||
|
||||
t.Run("ldd flag", func(t *testing.T) {
|
||||
cfg := &Config{General: GeneralConfig{}, Advanced: AdvancedConfig{LDD: true}, Network: NetworkConfig{}}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertContains(t, got, "--ldd")
|
||||
})
|
||||
|
||||
t.Run("add-exec flag", func(t *testing.T) {
|
||||
cfg := &Config{General: GeneralConfig{}, Advanced: AdvancedConfig{AddExec: true}, Network: NetworkConfig{}}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertContains(t, got, "--add-exec")
|
||||
})
|
||||
|
||||
t.Run("rw path included when exists", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Filesystem: FilesystemConfig{RW: []string{rwDir}},
|
||||
Network: NetworkConfig{},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertFlagValue(t, got, "--rw", rwDir)
|
||||
})
|
||||
|
||||
t.Run("ro path included when exists", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Filesystem: FilesystemConfig{RO: []string{roDir}},
|
||||
Network: NetworkConfig{},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertFlagValue(t, got, "--ro", roDir)
|
||||
})
|
||||
|
||||
t.Run("nonexistent path excluded", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Filesystem: FilesystemConfig{RW: []string{"/nonexistent/path/that/does/not/exist/xyz"}},
|
||||
Network: NetworkConfig{},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertDoesNotContain(t, got, "--rw")
|
||||
})
|
||||
|
||||
t.Run("CWD template expanded to real path", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Filesystem: FilesystemConfig{RW: []string{"{CWD}"}},
|
||||
Network: NetworkConfig{},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertFlagValue(t, got, "--rw", tmpDir)
|
||||
})
|
||||
|
||||
t.Run("unrestricted network", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Network: NetworkConfig{Enabled: true, Unrestricted: true},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertContains(t, got, "--unrestricted-network")
|
||||
assertDoesNotContain(t, got, "--connect-tcp")
|
||||
assertDoesNotContain(t, got, "--bind-tcp")
|
||||
})
|
||||
|
||||
t.Run("restricted network ports", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Network: NetworkConfig{
|
||||
Enabled: true,
|
||||
ConnectTCP: []string{"443", "80"},
|
||||
BindTCP: []string{"8080"},
|
||||
},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertFlagValue(t, got, "--connect-tcp", "443")
|
||||
assertFlagValue(t, got, "--connect-tcp", "80")
|
||||
assertFlagValue(t, got, "--bind-tcp", "8080")
|
||||
assertDoesNotContain(t, got, "--unrestricted-network")
|
||||
})
|
||||
|
||||
t.Run("network disabled ignores ports", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Network: NetworkConfig{Enabled: false, ConnectTCP: []string{"443"}},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertDoesNotContain(t, got, "--connect-tcp")
|
||||
assertDoesNotContain(t, got, "--unrestricted-network")
|
||||
})
|
||||
|
||||
t.Run("env vars", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Network: NetworkConfig{},
|
||||
Env: []string{"HOME", "PATH"},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertFlagValue(t, got, "--env", "HOME")
|
||||
assertFlagValue(t, got, "--env", "PATH")
|
||||
})
|
||||
|
||||
t.Run("parent path sorted before child", func(t *testing.T) {
|
||||
parent := filepath.Join(tmpDir, "parent")
|
||||
child := filepath.Join(tmpDir, "parent", "child")
|
||||
if err := os.MkdirAll(child, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &Config{
|
||||
General: GeneralConfig{},
|
||||
Advanced: AdvancedConfig{},
|
||||
Filesystem: FilesystemConfig{RW: []string{child, parent}}, // child listed first
|
||||
Network: NetworkConfig{},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
parentIdx := indexOf(got, parent)
|
||||
childIdx := indexOf(got, child)
|
||||
if parentIdx == -1 || childIdx == -1 {
|
||||
t.Fatal("parent or child path missing from args")
|
||||
}
|
||||
if parentIdx > childIdx {
|
||||
t.Errorf("parent (idx %d) should appear before child (idx %d) in args", parentIdx, childIdx)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- checkPath ----
|
||||
|
||||
func TestCheckPath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rwDir := filepath.Join(tmpDir, "rw")
|
||||
roDir := filepath.Join(tmpDir, "ro")
|
||||
if err := os.MkdirAll(rwDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(roDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
RO: []string{roDir},
|
||||
RW: []string{rwDir},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
write bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"rw path read allowed", filepath.Join(rwDir, "file.txt"), false, false},
|
||||
{"rw path write allowed", filepath.Join(rwDir, "file.txt"), true, false},
|
||||
{"ro path read allowed", filepath.Join(roDir, "file.txt"), false, false},
|
||||
{"ro path write denied", filepath.Join(roDir, "file.txt"), true, true},
|
||||
{"outside path read denied", "/outside/path/xyz", false, true},
|
||||
{"outside path write denied", "/outside/path/xyz", true, true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := checkPath(cfg, tc.path, tc.write, tmpDir, os.Getenv)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("checkPath(%q, write=%v) error = %v, wantErr %v", tc.path, tc.write, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("CWD template resolved", func(t *testing.T) {
|
||||
cfg2 := &Config{Filesystem: FilesystemConfig{RW: []string{"{CWD}"}}}
|
||||
err := checkPath(cfg2, filepath.Join(tmpDir, "newfile.txt"), true, tmpDir, os.Getenv)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RWX grants write access", func(t *testing.T) {
|
||||
rwxDir := filepath.Join(tmpDir, "rwx")
|
||||
if err := os.MkdirAll(rwxDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg3 := &Config{Filesystem: FilesystemConfig{RWX: []string{rwxDir}}}
|
||||
if err := checkPath(cfg3, filepath.Join(rwxDir, "bin"), true, tmpDir, os.Getenv); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- LoadSandbox ----
|
||||
|
||||
func TestLoadSandbox(t *testing.T) {
|
||||
t.Run("full config", func(t *testing.T) {
|
||||
yaml := []byte(`
|
||||
general:
|
||||
best_effort: true
|
||||
log_level: debug
|
||||
filesystem:
|
||||
ro:
|
||||
- /etc/passwd
|
||||
rox:
|
||||
- /usr
|
||||
rw:
|
||||
- "{CWD}"
|
||||
rwx:
|
||||
- "{XDG_CONFIG_HOME}/ollie"
|
||||
network:
|
||||
enabled: true
|
||||
unrestricted: false
|
||||
bind_tcp:
|
||||
- "8080"
|
||||
connect_tcp:
|
||||
- "443"
|
||||
env:
|
||||
- HOME
|
||||
- PATH
|
||||
advanced:
|
||||
ldd: true
|
||||
add_exec: false
|
||||
`)
|
||||
cfg, err := LoadSandbox(bytes.NewReader(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !cfg.General.BestEffort {
|
||||
t.Error("BestEffort should be true")
|
||||
}
|
||||
if cfg.General.LogLevel != "debug" {
|
||||
t.Errorf("LogLevel = %q, want %q", cfg.General.LogLevel, "debug")
|
||||
}
|
||||
if !containsStr(cfg.Filesystem.RO, "/etc/passwd") {
|
||||
t.Error("RO missing /etc/passwd")
|
||||
}
|
||||
if !containsStr(cfg.Filesystem.ROX, "/usr") {
|
||||
t.Error("ROX missing /usr")
|
||||
}
|
||||
if !containsStr(cfg.Filesystem.RW, "{CWD}") {
|
||||
t.Error("RW missing {CWD}")
|
||||
}
|
||||
if !containsStr(cfg.Filesystem.RWX, "{XDG_CONFIG_HOME}/ollie") {
|
||||
t.Error("RWX missing {XDG_CONFIG_HOME}/ollie")
|
||||
}
|
||||
if !cfg.Network.Enabled {
|
||||
t.Error("Network.Enabled should be true")
|
||||
}
|
||||
if cfg.Network.Unrestricted {
|
||||
t.Error("Network.Unrestricted should be false")
|
||||
}
|
||||
if !containsStr(cfg.Network.BindTCP, "8080") {
|
||||
t.Error("BindTCP missing 8080")
|
||||
}
|
||||
if !containsStr(cfg.Network.ConnectTCP, "443") {
|
||||
t.Error("ConnectTCP missing 443")
|
||||
}
|
||||
if !containsStr(cfg.Env, "HOME") || !containsStr(cfg.Env, "PATH") {
|
||||
t.Error("Env missing HOME or PATH")
|
||||
}
|
||||
if !cfg.Advanced.LDD {
|
||||
t.Error("Advanced.LDD should be true")
|
||||
}
|
||||
if cfg.Advanced.AddExec {
|
||||
t.Error("Advanced.AddExec should be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty yaml produces zero config", func(t *testing.T) {
|
||||
cfg, err := LoadSandbox(bytes.NewReader(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.General.BestEffort || cfg.General.LogLevel != "" {
|
||||
t.Error("expected zero GeneralConfig")
|
||||
}
|
||||
if len(cfg.Filesystem.RW) != 0 || len(cfg.Env) != 0 {
|
||||
t.Error("expected empty filesystem and env")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid yaml returns error", func(t *testing.T) {
|
||||
_, err := LoadSandbox(bytes.NewReader([]byte("{ not: valid: yaml: [")))
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid yaml")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial config leaves other fields zero", func(t *testing.T) {
|
||||
yaml := []byte(`
|
||||
env:
|
||||
- TERM
|
||||
`)
|
||||
cfg, err := LoadSandbox(bytes.NewReader(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !containsStr(cfg.Env, "TERM") {
|
||||
t.Error("Env missing TERM")
|
||||
}
|
||||
if len(cfg.Filesystem.RW) != 0 {
|
||||
t.Error("expected empty RW")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- expandPath: remaining branches ----
|
||||
|
||||
func TestExpandPath_HOME(t *testing.T) {
|
||||
got := expandPath("{HOME}/test", "/cwd", os.Getenv)
|
||||
home, _ := os.UserHomeDir()
|
||||
if got != home+"/test" {
|
||||
t.Errorf("expandPath({HOME}/test) = %q; want %q", got, home+"/test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath_XDG_Defaults(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
for _, tc := range []struct {
|
||||
varName, pattern, want string
|
||||
}{
|
||||
{"XDG_CONFIG_HOME", "{XDG_CONFIG_HOME}", filepath.Join(home, ".config")},
|
||||
{"XDG_DATA_HOME", "{XDG_DATA_HOME}", filepath.Join(home, ".local/share")},
|
||||
{"XDG_CACHE_HOME", "{XDG_CACHE_HOME}", filepath.Join(home, ".cache")},
|
||||
{"XDG_STATE_HOME", "{XDG_STATE_HOME}", filepath.Join(home, ".local/state")},
|
||||
{"XDG_RUNTIME_DIR", "{XDG_RUNTIME_DIR}", fmt.Sprintf("/run/user/%d", os.Getuid())},
|
||||
} {
|
||||
t.Run(tc.varName+"_default", func(t *testing.T) {
|
||||
t.Setenv(tc.varName, "")
|
||||
got := expandPath(tc.pattern, "/cwd", os.Getenv)
|
||||
if got != tc.want {
|
||||
t.Errorf("expandPath(%q) = %q; want %q", tc.pattern, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath_XDG_FromEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
varName, pattern string
|
||||
}{
|
||||
{"XDG_DATA_HOME", "{XDG_DATA_HOME}"},
|
||||
{"XDG_CACHE_HOME", "{XDG_CACHE_HOME}"},
|
||||
{"XDG_STATE_HOME", "{XDG_STATE_HOME}"},
|
||||
{"XDG_RUNTIME_DIR", "{XDG_RUNTIME_DIR}"},
|
||||
} {
|
||||
t.Run(tc.varName+"_env", func(t *testing.T) {
|
||||
t.Setenv(tc.varName, "/custom/"+tc.varName)
|
||||
got := expandPath(tc.pattern, "/cwd", os.Getenv)
|
||||
if got != "/custom/"+tc.varName {
|
||||
t.Errorf("expandPath(%q) = %q; want /custom/%s", tc.pattern, got, tc.varName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath_XDGPaths(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", "/custom/config")
|
||||
got1 := expandPath("{XDG_CONFIG_HOME}/ollie", "/cwd", os.Getenv)
|
||||
if got1 != "/custom/config/ollie" {
|
||||
t.Errorf("XDG_CONFIG_HOME/ollie not expanded: %q", got1)
|
||||
}
|
||||
t.Setenv("XDG_DATA_HOME", "/custom/data")
|
||||
got2 := expandPath("{XDG_DATA_HOME}/ollie", "/cwd", os.Getenv)
|
||||
if got2 != "/custom/data/ollie" {
|
||||
t.Errorf("XDG_DATA_HOME/ollie not expanded: %q", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- checkPath: remaining branches ----
|
||||
|
||||
func TestCheckPath_SymlinkResolved(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
realDir := filepath.Join(tmpDir, "real")
|
||||
os.MkdirAll(realDir, 0755)
|
||||
// Create a real file so EvalSymlinks succeeds on the full path
|
||||
realFile := filepath.Join(realDir, "file")
|
||||
os.WriteFile(realFile, []byte("x"), 0644)
|
||||
link := filepath.Join(tmpDir, "link")
|
||||
os.Symlink(realDir, link)
|
||||
|
||||
cfg := &Config{Filesystem: FilesystemConfig{RW: []string{realDir}}}
|
||||
// Access via symlink — EvalSymlinks resolves link/file to real/file
|
||||
if err := checkPath(cfg, filepath.Join(link, "file"), true, tmpDir, os.Getenv); err != nil {
|
||||
t.Errorf("symlink path should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPath_ROX_ReadAllowed(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
roxDir := filepath.Join(tmpDir, "rox")
|
||||
os.MkdirAll(roxDir, 0755)
|
||||
|
||||
cfg := &Config{Filesystem: FilesystemConfig{ROX: []string{roxDir}}}
|
||||
if err := checkPath(cfg, filepath.Join(roxDir, "bin"), false, tmpDir, os.Getenv); err != nil {
|
||||
t.Errorf("ROX read should be allowed: %v", err)
|
||||
}
|
||||
if err := checkPath(cfg, filepath.Join(roxDir, "bin"), true, tmpDir, os.Getenv); err == nil {
|
||||
t.Error("ROX write should be denied")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- LoadSandbox: reader error ----
|
||||
|
||||
func TestLoadSandbox_ReaderError(t *testing.T) {
|
||||
_, err := LoadSandbox(&errReader{})
|
||||
if err == nil {
|
||||
t.Error("expected error from bad reader")
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (errReader) Read([]byte) (int, error) { return 0, fmt.Errorf("read error") }
|
||||
|
||||
// ---- WrapCommand: remaining branches ----
|
||||
|
||||
func TestWrapCommand_LandrunUnavailable(t *testing.T) {
|
||||
old := isAvailableFn
|
||||
isAvailableFn = func() bool { return false }
|
||||
defer func() { isAvailableFn = old }()
|
||||
|
||||
cfg := &Config{}
|
||||
_, err := WrapCommand(cfg, []string{"echo", "hi"}, "/tmp", os.Getenv)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when landrun unavailable")
|
||||
}
|
||||
if err != ErrNotAvailable {
|
||||
t.Errorf("expected ErrNotAvailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapCommand_ROX_RWX(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
roxDir := filepath.Join(tmpDir, "rox")
|
||||
rwxDir := filepath.Join(tmpDir, "rwx")
|
||||
os.MkdirAll(roxDir, 0755)
|
||||
os.MkdirAll(rwxDir, 0755)
|
||||
|
||||
cfg := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
ROX: []string{roxDir},
|
||||
RWX: []string{rwxDir},
|
||||
},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertFlagValue(t, got, "--rox", roxDir)
|
||||
assertFlagValue(t, got, "--rwx", rwxDir)
|
||||
}
|
||||
|
||||
func TestWrapCommand_SortTiebreaker(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// Two paths of equal length
|
||||
dirA := filepath.Join(tmpDir, "aaa")
|
||||
dirB := filepath.Join(tmpDir, "bbb")
|
||||
os.MkdirAll(dirA, 0755)
|
||||
os.MkdirAll(dirB, 0755)
|
||||
|
||||
cfg := &Config{
|
||||
Filesystem: FilesystemConfig{RW: []string{dirB, dirA}},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
idxA := indexOf(got, dirA)
|
||||
idxB := indexOf(got, dirB)
|
||||
if idxA == -1 || idxB == -1 {
|
||||
t.Fatal("both dirs should be in args")
|
||||
}
|
||||
if idxA > idxB {
|
||||
t.Errorf("dirA (%q) should sort before dirB (%q)", dirA, dirB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapCommand_EnvVarExpansion(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dir1 := filepath.Join(tmpDir, "skills")
|
||||
os.MkdirAll(dir1, 0755)
|
||||
|
||||
t.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
|
||||
cfg := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
RWX: []string{"{XDG_CONFIG_HOME}/skills"},
|
||||
},
|
||||
}
|
||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||
assertFlagValue(t, got, "--rwx", dir1)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func mustWrapCommand(t *testing.T, cfg *Config, cmd []string, cwd string) []string {
|
||||
t.Helper()
|
||||
got, err := WrapCommand(cfg, cmd, cwd, os.Getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapCommand failed: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, args []string, s string) {
|
||||
t.Helper()
|
||||
if indexOf(args, s) == -1 {
|
||||
t.Errorf("args %v does not contain %q", args, s)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDoesNotContain(t *testing.T, args []string, s string) {
|
||||
t.Helper()
|
||||
if indexOf(args, s) != -1 {
|
||||
t.Errorf("args %v should not contain %q", args, s)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFlagValue(t *testing.T, args []string, flag, value string) {
|
||||
t.Helper()
|
||||
for i, a := range args {
|
||||
if a == flag && i+1 < len(args) && args[i+1] == value {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("args %v missing %s %s", args, flag, value)
|
||||
}
|
||||
|
||||
func indexOf(args []string, s string) int {
|
||||
for i, a := range args {
|
||||
if a == s {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func containsStr(ss []string, s string) bool {
|
||||
for _, x := range ss {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,94 +1,26 @@
|
|||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// pathEntry holds a path with its permission type
|
||||
type pathEntry struct {
|
||||
path string
|
||||
flag string // --ro, --rox, --rw, --rwx
|
||||
}
|
||||
|
||||
// isAvailableFn is overridable for testing.
|
||||
var (
|
||||
isAvailableFn = isAvailable
|
||||
)
|
||||
|
||||
// ErrNotAvailable is returned when landrun is not found on the system.
|
||||
var ErrNotAvailable = fmt.Errorf("SANDBOX FAILURE: landrun is not installed or not in PATH — shell CANNOT run without it")
|
||||
|
||||
// WrapCommand wraps a command with landrun based on the configuration.
|
||||
// Returns an error if landrun is not available.
|
||||
func WrapCommand(cfg *Config, originalCmd []string, cwd string, getenv EnvFunc) ([]string, error) {
|
||||
if !isAvailableFn() {
|
||||
return nil, ErrNotAvailable
|
||||
// NativeCommand encodes a native sandbox helper invocation.
|
||||
func NativeCommand(cfg *Config, originalCmd []string, cwd string, env map[string]string) ([]string, error) {
|
||||
envCopy := make(map[string]string, len(env))
|
||||
for k, v := range env {
|
||||
envCopy[k] = v
|
||||
}
|
||||
|
||||
args := []string{"landrun"}
|
||||
|
||||
if cfg.General.LogLevel != "" {
|
||||
args = append(args, "--log-level", cfg.General.LogLevel)
|
||||
data, err := json.Marshal(helperPolicy{Config: *cfg, CWD: cwd, Env: envCopy, Command: originalCmd})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.General.BestEffort {
|
||||
args = append(args, "--best-effort")
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.Advanced.LDD {
|
||||
args = append(args, "--ldd")
|
||||
}
|
||||
if cfg.Advanced.AddExec {
|
||||
args = append(args, "--add-exec")
|
||||
}
|
||||
|
||||
var entries []pathEntry
|
||||
addPaths := func(paths []string, flag string) {
|
||||
for _, path := range paths {
|
||||
p := expandPath(path, cwd, getenv)
|
||||
if p != "" && pathExists(p) {
|
||||
entries = append(entries, pathEntry{p, flag})
|
||||
}
|
||||
}
|
||||
}
|
||||
addPaths(cfg.Filesystem.RO, "--ro")
|
||||
addPaths(cfg.Filesystem.ROX, "--rox")
|
||||
addPaths(cfg.Filesystem.RW, "--rw")
|
||||
addPaths(cfg.Filesystem.RWX, "--rwx")
|
||||
|
||||
// Sort so parents come before children
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if len(entries[i].path) != len(entries[j].path) {
|
||||
return len(entries[i].path) < len(entries[j].path)
|
||||
}
|
||||
return entries[i].path < entries[j].path
|
||||
})
|
||||
|
||||
for _, e := range entries {
|
||||
args = append(args, e.flag, e.path)
|
||||
}
|
||||
|
||||
if cfg.Network.Unrestricted {
|
||||
args = append(args, "--unrestricted-network")
|
||||
} else if cfg.Network.Enabled {
|
||||
for _, port := range cfg.Network.BindTCP {
|
||||
args = append(args, "--bind-tcp", port)
|
||||
}
|
||||
for _, port := range cfg.Network.ConnectTCP {
|
||||
args = append(args, "--connect-tcp", port)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range cfg.Env {
|
||||
args = append(args, "--env", name)
|
||||
}
|
||||
|
||||
args = append(args, "--")
|
||||
args = append(args, originalCmd...)
|
||||
|
||||
return args, nil
|
||||
return []string{exe, "sandbox-exec", base64.RawStdEncoding.EncodeToString(data)}, nil
|
||||
}
|
||||
|
||||
// pathExists checks if a file or directory exists
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
|
@ -31,6 +30,7 @@ import (
|
|||
p9client "9fans.net/go/plan9/client"
|
||||
|
||||
"ollie/cmd/toolsrv/internal/registry"
|
||||
"ollie/cmd/toolsrv/internal/sandbox"
|
||||
"ollie/cmd/toolsrv/internal/server"
|
||||
|
||||
olog "ollie/log"
|
||||
|
|
@ -59,6 +59,13 @@ var hadConnection atomic.Bool
|
|||
var procInterruptWG sync.WaitGroup
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "sandbox-exec" {
|
||||
if err := sandbox.ExecHelper(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) < 2 || os.Args[1] != "serve" {
|
||||
fmt.Fprintln(os.Stderr, "usage: toolsrv serve --cwd <path> --listen <socket> [--yolo]")
|
||||
os.Exit(1)
|
||||
|
|
@ -80,10 +87,7 @@ func main() {
|
|||
|
||||
util.EnsureEnv()
|
||||
|
||||
// Prepend our bin dir to PATH so landrun is found (deployed alongside us).
|
||||
home, _ := os.UserHomeDir()
|
||||
binDir := filepath.Join(home, ".config", "ollie", "bin")
|
||||
os.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
|
||||
// Native Landlock is applied by the sandbox-exec child; no external wrapper is required.
|
||||
|
||||
// Create tool registry
|
||||
toolReg, _ := registry.New()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Sandbox overrides for the test container.
|
||||
# Container has no landrun, so this is documentation for now.
|
||||
# Container uses the native Landlock implementation in toolsrv.
|
||||
# Use --yolo for initial testing.
|
||||
filesystem:
|
||||
rox:
|
||||
|
|
|
|||
|
|
@ -47,19 +47,15 @@ case "${1:-start}" in
|
|||
;;
|
||||
|
||||
test)
|
||||
# Full integration test:
|
||||
# 1. Copy ollie-remote to container
|
||||
# 2. Run a command via JSON-RPC over SSH
|
||||
echo "=== Deploying ollie-remote + landrun to container ==="
|
||||
# Full integration test deploys only the toolsrv binary; sandboxing is native Landlock.
|
||||
cd "$(dirname "$0")/.."
|
||||
GOOS=linux GOARCH=amd64 go build -o test/ollie-remote-bin .
|
||||
|
||||
# Copy both binaries (mirrors real bootstrap behavior)
|
||||
LANDRUN=$(which landrun)
|
||||
# Copy the toolsrv binary (sandboxing is implemented inside it).
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-P "$PORT" test/ollie-remote-bin "$LANDRUN" lkn@localhost:~/.cache/ollie/bin/
|
||||
-P "$PORT" test/ollie-remote-bin lkn@localhost:~/.cache/ollie/bin/
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-p "$PORT" lkn@localhost 'mv ~/.cache/ollie/bin/ollie-remote-bin ~/.cache/ollie/bin/ollie-remote && chmod +x ~/.cache/ollie/bin/ollie-remote ~/.cache/ollie/bin/landrun'
|
||||
-p "$PORT" lkn@localhost 'mv ~/.cache/ollie/bin/ollie-remote-bin ~/.cache/ollie/bin/ollie-remote && chmod +x ~/.cache/ollie/bin/ollie-remote'
|
||||
rm -f test/ollie-remote-bin
|
||||
|
||||
echo
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ flowchart TB
|
|||
SERVER["server — 9P tool server and proc handlers"]
|
||||
REGISTRY["registry — per-agent loaded tools"]
|
||||
EXEC["exec — tool process execution"]
|
||||
SANDBOX["sandbox — Landlock/landrun policy"]
|
||||
SANDBOX["sandbox — native Landlock policy"]
|
||||
end
|
||||
|
||||
Agent --> Backend
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ The following remain local:
|
|||
1. Validate that the local Ollie configuration directory exists.
|
||||
2. Create a local forwarded socket path and a temporary remote socket path.
|
||||
3. Start `ssh` with local Unix-socket forwarding (`-L`).
|
||||
4. Stream a gzipped tar archive of the local Ollie configuration directory to the remote shell.
|
||||
4. Stream a gzipped tar archive of the local Ollie configuration directory to the remote shell; the archive contains the toolsrv binary and configuration, but no external sandbox executable.
|
||||
5. Run a remote bootstrap script through `bash -s`.
|
||||
6. Extract the archive into `${XDG_CACHE_HOME:-$HOME/.cache}/ollie`.
|
||||
7. Set remote `XDG_CONFIG_HOME` and prepend the cached `bin` directory to `PATH`.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ flowchart LR
|
|||
S --> P[Process State]
|
||||
P --> M[Metadata resolver]
|
||||
P --> E[Tool executor]
|
||||
E --> X[Landlock / landrun sandbox]
|
||||
E --> X[Native Landlock sandbox helper]
|
||||
E --> B[Bypass broker]
|
||||
E --> T[Installed tool command]
|
||||
```
|
||||
|
|
@ -36,7 +36,7 @@ Startup sequence:
|
|||
1. Require the `serve` subcommand and a `--listen` path.
|
||||
2. Expand `~` in `--cwd`.
|
||||
3. Create the log sink and call `util.EnsureEnv()`.
|
||||
4. Prepend the installed Ollie bin directory to `PATH` so `landrun` can be found.
|
||||
4. Start the native Landlock sandbox helper for each restricted tool execution.
|
||||
5. Create the per-agent registry and the server state.
|
||||
6. Apply `--yolo` to the server and process state.
|
||||
7. Build the 9P tree from `server.Spec`.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ flowchart TB
|
|||
A --> B[provider backend]
|
||||
A --> T[toolsrv]
|
||||
T --> R[tool registry]
|
||||
T --> X[landrun / Landlock sandbox]
|
||||
T --> X[native Landlock sandbox helper]
|
||||
T --> P[bypass broker]
|
||||
```
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ The loop resolves system and user prompts, renders context, calls the configured
|
|||
|
||||
`toolsrv` is a separate process connected through 9P. It owns tool discovery, metadata, lazy registry loading, process lifecycle, output streaming, and sandbox invocation. Tool implementations are installed executables or scripts; they are not compiled into the agent loop. The same service can expose local and remotely deployed tools.
|
||||
|
||||
The tool namespace also provides process and sandbox state. Normal execution is restricted by configured Landlock/landrun policy. The bypass broker is a policy-controlled path for explicitly approved operations outside that policy.
|
||||
The tool namespace also provides process and sandbox state. Normal execution is restricted by the configured native Landlock policy. The bypass broker is a policy-controlled path for explicitly approved operations outside that policy.
|
||||
|
||||
## Deliberately unimplemented agent patterns
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ gantt
|
|||
section Execution
|
||||
execute_code / shell :done, 2026-05-08, 20d
|
||||
Batch jobs (b/ → session/bfg) :done, 2026-05-15, 25d
|
||||
Sandbox (landrun) :done, 2026-05-20, 40d
|
||||
Sandbox (native Landlock) :done, 2026-05-20, 40d
|
||||
section Multi-Agent
|
||||
Subagent spawn :done, 2026-05-25, 15d
|
||||
Cascade orchestrator :done, 2026-06-05, 20d
|
||||
|
|
@ -175,7 +175,7 @@ flowchart LR
|
|||
end
|
||||
subgraph Execution["Execution Sandbox"]
|
||||
AGT["Agent"]
|
||||
SANDBOX["landrun sandbox\n(restricted fs)"]
|
||||
SANDBOX["native Landlock sandbox\n(restricted fs)"]
|
||||
TOOLS["Tool definitions"]
|
||||
BYPASS["x/bypass\n(socket broker)"]
|
||||
PRIV["Privileged Action"]
|
||||
|
|
@ -185,7 +185,7 @@ flowchart LR
|
|||
```
|
||||
Evolution:
|
||||
1. No sandboxing initially
|
||||
2. YAML-based sandbox configs (landrun) for execute_code
|
||||
2. YAML-based native Landlock sandbox configs for execute_code
|
||||
3. Per-session 9P identity — agents can't read other sessions' tools
|
||||
4. Bypass broker: socket-based, user-confirmed privilege escalation
|
||||
5. SSH agent proxy added then removed (too much attack surface)
|
||||
|
|
@ -715,7 +715,7 @@ The SSH bootstrap sequence was cut from a complex multi-stage protocol to a sing
|
|||
| gzip + base64 binary encoding | Raw `tar czf` tarball |
|
||||
| `OLLIE_LOADER_START` / `OLLIE_LOADER_READY` handshake | Single `OLLIE_LISTEN_READY` signal |
|
||||
| Separate `bootstrap.sh` template (88 lines) | Inline 10-line script |
|
||||
| Per-binary transfer (ollie-remote + landrun) | Single `tar` of `~/.config/ollie/` |
|
||||
| Per-binary transfer (ollie-remote + native sandbox) | Single `tar` of `~/.config/ollie/` |
|
||||
|
||||
The simplified bootstrap:
|
||||
|
||||
|
|
@ -1132,9 +1132,9 @@ Two distinct components:
|
|||
1. **`cmd/toolsrv/`** — a standalone binary that serves its own 9P2000
|
||||
filesystem over a Unix socket. Has its own `internal/` packages:
|
||||
- `internal/fs/` — filesystem spec (fsedsl), server state, process management
|
||||
- `internal/exec/` — sandboxed tool execution (landrun, bypass broker)
|
||||
- `internal/exec/` — sandboxed tool execution (native Landlock, bypass broker)
|
||||
- `internal/registry/` — session-scoped tool registry
|
||||
- `internal/sandbox/` — landrun configuration (unchanged)
|
||||
- `internal/sandbox/` — native Landlock configuration and enforcement
|
||||
|
||||
2. **`toolsrv/`** (root package) — a 9P client library. `Conn` dials toolsrv
|
||||
over a Unix socket, authenticates via Tauth, and exposes methods like
|
||||
|
|
@ -1387,7 +1387,7 @@ Both memory tools are auto-loaded by every agent profile. Shared prompt
|
|||
guidance tells agents to recall relevant prior context before acting and
|
||||
remember durable decisions, outcomes, preferences, and non-obvious findings.
|
||||
The OptMem executable is installed under `$XDG_CONFIG_HOME/ollie/optmem` and
|
||||
explicitly granted `rwx` access by the landrun sandbox.
|
||||
explicitly granted `rwx` access by the native Landlock sandbox.
|
||||
|
||||
This integration keeps memory as an ordinary Ollie tool while giving it a
|
||||
durable, searchable backend. It requires no MCP server, daemon, or new
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -29,6 +29,7 @@ require (
|
|||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.30.0
|
||||
)
|
||||
|
||||
replace ollie/virtfs => ./virtfs
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -121,6 +121,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
|
|
|
|||
Loading…
Reference in New Issue