replace landrun with native Landlock sandbox

This commit is contained in:
Ollie Agent 2026-08-18 12:36:27 +02:00
parent 09729a9149
commit bdafc30022
22 changed files with 168 additions and 836 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

@ -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"`

View File

@ -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
}

View File

@ -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

View File

@ -0,0 +1,7 @@
//go:build !linux
package sandbox
import "fmt"
func ExecHelper([]string) error { return fmt.Errorf("native Landlock sandbox requires Linux") }

View File

@ -1,4 +1,4 @@
# Default execute_code sandbox configuration.
# native Landlock sandbox configuration
filesystem:
ro:
- "{XDG_CONFIG_HOME}/git"

View File

@ -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
}

View File

@ -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
// 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
}
// 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
data, err := json.Marshal(helperPolicy{Config: *cfg, CWD: cwd, Env: envCopy, Command: originalCmd})
if err != nil {
return nil, err
}
args := []string{"landrun"}
if cfg.General.LogLevel != "" {
args = append(args, "--log-level", cfg.General.LogLevel)
exe, err := os.Executable()
if err != nil {
return nil, err
}
if cfg.General.BestEffort {
args = append(args, "--best-effort")
}
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

View File

@ -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()

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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`.

View File

@ -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`.

View File

@ -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

View File

@ -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
View File

@ -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
View File

@ -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=