diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go index 59dc8ad62..ace95f44d 100644 --- a/pkg/agent/hook_process.go +++ b/pkg/agent/hook_process.go @@ -12,6 +12,7 @@ import ( "sync/atomic" "time" + "github.com/sipeed/picoclaw/pkg/isolation" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -122,7 +123,9 @@ func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) ( if err != nil { return nil, fmt.Errorf("create process hook stderr: %w", err) } - if err := cmd.Start(); err != nil { + // Route hook subprocess startup through the shared isolation entry point so + // process hooks inherit the same isolation behavior as other child processes. + if err := isolation.Start(cmd); err != nil { return nil, fmt.Errorf("start process hook: %w", err) } diff --git a/pkg/agent/hook_process_test.go b/pkg/agent/hook_process_test.go index 50f89811f..9e95d105e 100644 --- a/pkg/agent/hook_process_test.go +++ b/pkg/agent/hook_process_test.go @@ -7,10 +7,13 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "testing" "time" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/isolation" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -178,6 +181,76 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) { } } +func TestAgentLoop_MountProcessHook_IsolationSupportsRelativeDirAndCommand(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-only isolation path handling") + } + + provider := &llmHookTestProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + root := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(root, "picoclaw-home")) + binDir := filepath.Join(root, "bin") + hookDir := filepath.Join(root, "hooks") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatal(err) + } + writeFakeBwrap(t, filepath.Join(binDir, "bwrap")) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + linkTestBinary(t, os.Args[0], filepath.Join(hookDir, "hook-helper")) + + cfg := config.DefaultConfig() + cfg.Isolation.Enabled = true + isolation.Configure(cfg) + t.Cleanup(func() { isolation.Configure(config.DefaultConfig()) }) + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relHookDir, err := filepath.Rel(cwd, hookDir) + if err != nil { + t.Fatal(err) + } + + mountErr := al.MountProcessHook(context.Background(), "ipc-relative", ProcessHookOptions{ + Command: []string{"./hook-helper", "-test.run=TestProcessHook_HelperProcess", "--"}, + Dir: relHookDir, + Env: processHookHelperEnv("rewrite", ""), + InterceptLLM: true, + }) + if mountErr != nil { + t.Fatalf("MountProcessHook failed with relative dir/command under isolation: %v", mountErr) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-relative", + Channel: "cli", + ChatID: "direct", + UserMessage: "hello", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "provider content|ipc" { + t.Fatalf("expected process-hooked llm content, got %q", resp) + } + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "process-model" { + t.Fatalf("expected process model, got %q", lastModel) + } +} + func processHookHelperCommand() []string { return []string{os.Args[0], "-test.run=TestProcessHook_HelperProcess", "--"} } @@ -193,6 +266,59 @@ func processHookHelperEnv(mode, eventLog string) []string { return env } +func writeFakeBwrap(t *testing.T, path string) { + t.Helper() + script := `#!/bin/sh +set -eu +workdir= +while [ "$#" -gt 0 ]; do + case "$1" in + --) + shift + break + ;; + --chdir) + workdir="$2" + shift 2 + ;; + --bind|--ro-bind) + shift 3 + ;; + --proc|--dev) + shift 2 + ;; + --die-with-parent|--unshare-ipc) + shift + ;; + *) + shift + ;; + esac +done +if [ -n "$workdir" ]; then + cd "$workdir" +fi +exec "$@" +` + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write fake bwrap: %v", err) + } +} + +func linkTestBinary(t *testing.T, source, target string) { + t.Helper() + if err := os.Symlink(source, target); err == nil { + return + } + data, err := os.ReadFile(source) + if err != nil { + t.Fatalf("read test binary: %v", err) + } + if err := os.WriteFile(target, data, 0o755); err != nil { + t.Fatalf("create hook helper binary: %v", err) + } +} + func waitForFileContains(t *testing.T, path, substring string) { t.Helper() diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 48e5aa625..5bcb83087 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/isolation" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/memory" @@ -64,6 +65,12 @@ func NewAgentInstance( cfg *config.Config, provider providers.LLMProvider, ) *AgentInstance { + if cfg != nil { + // Keep the subprocess isolation runtime aligned with the latest loaded config + // before any tools or providers start spawning child processes. + isolation.Configure(cfg) + } + workspace := resolveAgentWorkspace(agentCfg, defaults) os.MkdirAll(workspace, 0o755) diff --git a/pkg/config/config.go b/pkg/config/config.go index 606f7a095..fd4466b8c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,20 +24,21 @@ var rrCounter atomic.Uint64 // CurrentVersion is the latest config schema version const CurrentVersion = 2 -// Config is the current config structure with version support +// Config is the current config structure with version support. type Config struct { - Version int `json:"version" yaml:"-"` // Config schema version for migration - Agents AgentsConfig `json:"agents" yaml:"-"` - Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"` - Session SessionConfig `json:"session,omitempty" yaml:"-"` - Channels ChannelsConfig `json:"channels" yaml:"channels"` - ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway" yaml:"-"` - Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` - Tools ToolsConfig `json:"tools" yaml:",inline"` - Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` - Devices DevicesConfig `json:"devices" yaml:"-"` - Voice VoiceConfig `json:"voice" yaml:"-"` + Version int `json:"version" yaml:"-"` // Config schema version for migration + Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"` + Agents AgentsConfig `json:"agents" yaml:"-"` + Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"` + Session SessionConfig `json:"session,omitempty" yaml:"-"` + Channels ChannelsConfig `json:"channels" yaml:"channels"` + ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration + Gateway GatewayConfig `json:"gateway" yaml:"-"` + Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` + Tools ToolsConfig `json:"tools" yaml:",inline"` + Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` + Devices DevicesConfig `json:"devices" yaml:"-"` + Voice VoiceConfig `json:"voice" yaml:"-"` // BuildInfo contains build-time version information BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"` @@ -45,6 +46,21 @@ type Config struct { sensitiveCache *SensitiveDataCache } +// IsolationConfig controls subprocess isolation for commands started by PicoClaw. +// It is applied by the isolation package rather than by sandboxing the main process. +type IsolationConfig struct { + Enabled bool `json:"enabled,omitempty"` + ExposePaths []ExposePath `json:"expose_paths,omitempty"` +} + +// ExposePath describes a host path that should remain visible inside the isolated +// child-process environment. This is currently implemented on Linux only. +type ExposePath struct { + Source string `json:"source"` + Target string `json:"target,omitempty"` + Mode string `json:"mode"` +} + // FilterSensitiveData filters sensitive values from content before sending to LLM. // This prevents the LLM from seeing its own credentials. // Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig). diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1c6b784c7..f0449d98f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -852,6 +852,37 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { } } +func TestDefaultConfig_IsolationEnabled(t *testing.T) { + cfg := DefaultConfig() + if cfg.Isolation.Enabled { + t.Fatal("DefaultConfig().Isolation.Enabled should be false") + } +} + +func TestConfig_UnmarshalIsolation(t *testing.T) { + cfg := DefaultConfig() + raw := []byte(`{ + "isolation": { + "enabled": false, + "expose_paths": [ + {"source":"/src","target":"/dst","mode":"ro"} + ] + } + }`) + if err := json.Unmarshal(raw, cfg); err != nil { + t.Fatalf("json.Unmarshal isolation config: %v", err) + } + if cfg.Isolation.Enabled { + t.Fatal("Isolation.Enabled should be false after unmarshal") + } + if len(cfg.Isolation.ExposePaths) != 1 { + t.Fatalf("ExposePaths len = %d, want 1", len(cfg.Isolation.ExposePaths)) + } + if got := cfg.Isolation.ExposePaths[0]; got.Source != "/src" || got.Target != "/dst" || got.Mode != "ro" { + t.Fatalf("ExposePaths[0] = %+v, want source=/src target=/dst mode=ro", got) + } +} + // TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators func TestFlexibleStringSlice_UnmarshalText(t *testing.T) { tests := []struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index c2e1a31f3..bb073d436 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -17,6 +17,11 @@ func DefaultConfig() *Config { return &Config{ Version: CurrentVersion, + // Isolation is opt-in so existing installations keep their current behavior + // until the user explicitly enables subprocess sandboxing. + Isolation: IsolationConfig{ + Enabled: false, + }, Agents: AgentsConfig{ Defaults: AgentDefaults{ Workspace: workspacePath, diff --git a/pkg/isolation/README.md b/pkg/isolation/README.md new file mode 100644 index 000000000..de16ce505 --- /dev/null +++ b/pkg/isolation/README.md @@ -0,0 +1,238 @@ +# `pkg/isolation` + +`pkg/isolation` provides process-level isolation for child processes started by `picoclaw`. + +It does not sandbox the main `picoclaw` process itself. + +## Scope + +The current scope is the child-process startup path: + +- `exec` tool +- CLI providers such as `claude-cli` and `codex-cli` +- process hooks +- MCP `stdio` servers + +## One-Sentence Model + +- The `picoclaw` main process still runs in the host environment. +- Every child process should enter the shared `pkg/isolation` startup path first. +- The startup path applies platform-specific isolation according to config. + +## Architecture + +The implementation has four layers: + +1. Configuration layer: reads `config.Config.Isolation` and injects it through `isolation.Configure(cfg)`. +2. Instance layout layer: resolves `config.GetHome()`, prepares instance directories, and builds the runtime user environment. +3. Platform backend layer: Linux uses `bwrap`; Windows uses a restricted token, low integrity, and a `Job Object`; other platforms are not implemented. +4. Unified startup layer: `PrepareCommand(cmd)`, `Start(cmd)`, and `Run(cmd)`. + +All integrations that spawn subprocesses should reuse these helpers instead of calling `cmd.Start` or `cmd.Run` directly. + +## Configuration + +Isolation lives under: + +```json +{ + "isolation": { + "enabled": false, + "expose_paths": [] + } +} +``` + +Field meanings: + +- `enabled`: enables or disables subprocess isolation. Default: `false`. +- `expose_paths`: explicitly exposes host paths inside the isolated environment. It only matters when `enabled=true`. This is currently supported on Linux only. + +Example: + +```json +{ + "isolation": { + "enabled": true, + "expose_paths": [ + { + "source": "/opt/toolchains/go", + "target": "/opt/toolchains/go", + "mode": "ro" + }, + { + "source": "/data/shared-assets", + "target": "/opt/picoclaw-instance-a/workspace/assets", + "mode": "rw" + } + ] + } +} +``` + +Rules for `expose_paths`: + +- `source` is a host path. +- `target` is the path inside the isolated environment. +- `mode` must be `ro` or `rw`. +- When `target` is empty, it defaults to `source`. +- Only one final rule may exist for the same `target`. +- Later-loaded config overrides earlier rules for the same `target`. + +Platform note: + +- Linux uses a real `source -> target` mount view. +- Windows does not currently support `expose_paths`. + +## Instance Root And Directories + +The instance root follows `config.GetHome()`: + +- If `PICOCLAW_HOME` is set, use it. +- Otherwise use the default `.picoclaw` directory under the user home. + +If `config.GetHome()` falls back to `.` while isolation is enabled, startup should fail. + +Default instance directories include: + +- instance root +- `skills` +- `logs` +- `cache` +- `state` +- `runtime-user-env` + +`workspace` is derived from `cfg.WorkspacePath()` when configured, otherwise from the default workspace rule. + +Windows also prepares: + +- `runtime-user-env/AppData/Roaming` +- `runtime-user-env/AppData/Local` + +## User Environment Redirect + +When isolation is enabled, child processes receive a redirected per-instance user environment. + +Linux variables: + +- `HOME` +- `TMPDIR` +- `XDG_CONFIG_HOME` +- `XDG_CACHE_HOME` +- `XDG_STATE_HOME` + +Windows variables: + +- `USERPROFILE` +- `HOME` +- `TEMP` +- `TMP` +- `APPDATA` +- `LOCALAPPDATA` + +These paths point into `runtime-user-env` under the instance root. + +## Platform Behavior + +### Linux + +The Linux backend currently depends on `bwrap` (`bubblewrap`). + +Capabilities: + +- minimal filesystem view +- `ipc` namespace isolation +- redirected child-process user environment +- `source -> target` read-only or read-write mounts + +Default mounts include the instance root plus the minimum runtime system paths such as `/usr`, `/bin`, `/lib`, `/lib64`, and `/etc/resolv.conf`. + +At runtime, PicoClaw also adds the executable path, its directory, the effective working directory, and absolute path arguments when needed. + +There is no automatic fallback when `bwrap` is missing. + +Install examples: + +- `apt install bubblewrap` +- `dnf install bubblewrap` +- `yum install bubblewrap` +- `pacman -S bubblewrap` +- `apk add bubblewrap` + +If isolation must be disabled temporarily: + +```json +{ + "isolation": { + "enabled": false + } +} +``` + +Disabling isolation increases the risk that child processes can access or modify more host files. + +### Windows + +Windows isolation currently supports process-level restrictions such as restricted tokens, low integrity, job objects, and redirected user-environment directories. + +`expose_paths` is not currently supported on Windows. If it is configured, startup should fail instead of pretending the paths were exposed. + +The Windows backend currently uses: + +- a restricted primary token +- low integrity level +- a `Job Object` +- redirected child-process user environment + +It does not currently implement true `source -> target` filesystem remapping. + +### macOS And Other Platforms + +They are not implemented yet. + +When isolation is explicitly enabled on an unsupported platform, the higher-level runtime should surface that as an unsupported configuration instead of pretending isolation succeeded. + +## Logging And Debugging + +When isolation is enabled, PicoClaw logs the generated isolation plan. + +Linux log name: + +- `linux isolation mount plan` + +Windows log name: + +- `windows isolation access rules` + +If you suspect isolation is ineffective, check whether unexpected host paths appear in those logs. + +## Relationship To `restrict_to_workspace` + +- `restrict_to_workspace` limits the paths an agent is normally allowed to access. +- `pkg/isolation` limits what a child process can see and where its user environment points. + +They complement each other and do not replace each other. + +## Current Limits + +- Linux isolation is implemented with `bwrap`, not a custom in-process isolation runtime. +- Linux does not currently enable a dedicated `pid` namespace by default. +- Windows does not yet implement full host ACL enforcement for every allowed or denied path. +- macOS is not implemented. +- The current design isolates child processes, not the main `picoclaw` process. + +## Suggested Reading Order + +If you are new to this code, read it in this order: + +1. `pkg/config/config.go` +2. `pkg/isolation/runtime.go` +3. `pkg/isolation/platform_linux.go` +4. `pkg/isolation/platform_windows.go` +5. Call sites: +6. `pkg/tools/shell.go` +7. `pkg/providers/*.go` +8. `pkg/agent/hook_process.go` +9. `pkg/mcp/manager.go` + +That path gives the fastest overview of the configuration model, runtime flow, and platform-specific limits. diff --git a/pkg/isolation/README_CN.md b/pkg/isolation/README_CN.md new file mode 100644 index 000000000..0529a84bd --- /dev/null +++ b/pkg/isolation/README_CN.md @@ -0,0 +1,238 @@ +# `pkg/isolation` + +`pkg/isolation` 为 `picoclaw` 启动的子进程提供进程级隔离能力。 + +它当前不会把 `picoclaw` 主进程自身放进沙箱中运行。 + +## 生效范围 + +当前生效范围是子进程启动链路: + +- `exec` 工具 +- `claude-cli`、`codex-cli` 等 CLI provider +- 进程型 hooks +- MCP `stdio` server + +## 一句话理解 + +- `picoclaw` 主进程仍运行在宿主环境中。 +- 所有子进程都应先经过 `pkg/isolation` 的统一启动入口。 +- 入口会根据配置和平台,为子进程施加对应隔离。 + +## 架构 + +当前实现可以分为四层: + +1. 配置层:读取 `config.Config.Isolation`,并通过 `isolation.Configure(cfg)` 注入运行时。 +2. 实例目录层:解析 `config.GetHome()`,准备实例目录,并构建运行时用户环境目录。 +3. 平台后端层:Linux 使用 `bwrap`;Windows 使用受限 token、低完整性级别和 `Job Object`;其他平台未实现。 +4. 统一启动层:`PrepareCommand(cmd)`、`Start(cmd)`、`Run(cmd)`。 + +所有启动子进程的接入点都应复用这组入口,而不是各自直接调用 `cmd.Start` 或 `cmd.Run`。 + +## 配置 + +隔离配置位于: + +```json +{ + "isolation": { + "enabled": false, + "expose_paths": [] + } +} +``` + +字段说明: + +- `enabled`:是否启用子进程隔离。默认值:`false`。 +- `expose_paths`:显式把宿主路径带入隔离环境。仅在 `enabled=true` 时生效。目前只在 Linux 上支持。 + +示例: + +```json +{ + "isolation": { + "enabled": true, + "expose_paths": [ + { + "source": "/opt/toolchains/go", + "target": "/opt/toolchains/go", + "mode": "ro" + }, + { + "source": "/data/shared-assets", + "target": "/opt/picoclaw-instance-a/workspace/assets", + "mode": "rw" + } + ] + } +} +``` + +`expose_paths` 规则: + +- `source`:宿主机路径。 +- `target`:隔离环境内的目标路径。 +- `mode`:只能是 `ro` 或 `rw`。 +- `target` 为空时,默认等于 `source`。 +- 同一个 `target` 最终只能保留一条规则。 +- 后加载的配置会覆盖先加载的同目标规则。 + +平台说明: + +- Linux 会真实使用 `source -> target` 挂载视图。 +- Windows 当前不支持 `expose_paths`。 + +## 实例根与目录 + +实例根遵循 `config.GetHome()`: + +- 如果设置了 `PICOCLAW_HOME`,使用该值。 +- 否则默认使用用户目录下的 `.picoclaw`。 + +如果 `config.GetHome()` 在隔离开启时最终回退到当前目录 `.`,启动应直接失败。 + +默认实例目录包括: + +- 实例根本身 +- `skills` +- `logs` +- `cache` +- `state` +- `runtime-user-env` + +`workspace` 优先使用 `cfg.WorkspacePath()` 的结果;未显式配置时才按默认规则派生。 + +Windows 还会额外准备: + +- `runtime-user-env/AppData/Roaming` +- `runtime-user-env/AppData/Local` + +## 用户环境重定向 + +隔离开启后,子进程会收到重定向到实例目录下的独立用户环境。 + +Linux 注入变量: + +- `HOME` +- `TMPDIR` +- `XDG_CONFIG_HOME` +- `XDG_CACHE_HOME` +- `XDG_STATE_HOME` + +Windows 注入变量: + +- `USERPROFILE` +- `HOME` +- `TEMP` +- `TMP` +- `APPDATA` +- `LOCALAPPDATA` + +这些路径都会指向实例根下的 `runtime-user-env`。 + +## 平台行为 + +### Linux + +Linux 后端当前依赖 `bwrap`(`bubblewrap`)。 + +能力: + +- 最小文件系统视图 +- `ipc namespace` +- 子进程用户环境重定向 +- `source -> target` 只读或读写挂载 + +默认映射包括实例根,以及 `/usr`、`/bin`、`/lib`、`/lib64`、`/etc/resolv.conf` 等最小运行时系统路径。 + +运行时还会按需补充可执行文件本身、其所在目录、生效后的工作目录,以及命令行中的绝对路径参数。 + +缺少 `bwrap` 时不会自动回退。 + +安装示例: + +- `apt install bubblewrap` +- `dnf install bubblewrap` +- `yum install bubblewrap` +- `pacman -S bubblewrap` +- `apk add bubblewrap` + +如果需要临时关闭隔离: + +```json +{ + "isolation": { + "enabled": false + } +} +``` + +关闭隔离后,子进程访问或修改更多宿主文件的风险会明显上升。 + +### Windows + +Windows 隔离当前提供的是进程级限制,例如 restricted token、low integrity、job object,以及用户环境目录重定向。 + +`expose_paths` 目前不支持 Windows。如果配置了该字段,启动应直接失败,而不是假装这些路径已经被暴露进隔离环境。 + +Windows 后端当前使用: + +- 受限 primary token +- 低完整性级别 +- `Job Object` +- 子进程用户环境重定向 + +它当前不会实现真正的 `source -> target` 文件系统重映射。 + +### macOS 与其他平台 + +当前尚未实现。 + +当在未支持的平台上显式开启隔离时,上层运行时应将其视为不支持的配置,而不是假装隔离成功。 + +## 日志与排障 + +隔离开启后,PicoClaw 会打印生成后的隔离计划,便于排障。 + +Linux 日志名: + +- `linux isolation mount plan` + +Windows 日志名: + +- `windows isolation access rules` + +如果你怀疑隔离未生效,先检查这些日志里是否出现了不应暴露的宿主路径。 + +## 与 `restrict_to_workspace` 的关系 + +- `restrict_to_workspace` 限制的是 agent 默认可访问的路径。 +- `pkg/isolation` 限制的是子进程运行时能看到什么文件系统,以及它的用户环境指向哪里。 + +两者互补,不互相替代。 + +## 当前限制 + +- Linux 基于 `bwrap` 实现,而不是纯内建 isolation runtime。 +- Linux 当前没有默认启用独立的 `pid namespace`。 +- Windows 还没有对所有允许/拒绝路径做完整 ACL 落地。 +- macOS 尚未实现。 +- 当前隔离的是子进程,不是 `picoclaw` 主进程自身。 + +## 建议阅读顺序 + +如果你是第一次看这部分代码,建议按这个顺序阅读: + +1. `pkg/config/config.go` +2. `pkg/isolation/runtime.go` +3. `pkg/isolation/platform_linux.go` +4. `pkg/isolation/platform_windows.go` +5. 调用点: +6. `pkg/tools/shell.go` +7. `pkg/providers/*.go` +8. `pkg/agent/hook_process.go` +9. `pkg/mcp/manager.go` + +这样能最快建立对配置模型、运行流程和平台边界的整体理解。 diff --git a/pkg/isolation/platform_linux.go b/pkg/isolation/platform_linux.go new file mode 100644 index 000000000..9a282a4ad --- /dev/null +++ b/pkg/isolation/platform_linux.go @@ -0,0 +1,264 @@ +//go:build linux + +package isolation + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + if !isolation.Enabled { + return nil + } + // Bubblewrap is the only supported Linux backend right now. Fail closed when + // it is unavailable instead of silently running the child process unisolated. + bwrapPath, err := exec.LookPath("bwrap") + if err != nil { + hint := bwrapInstallHint() + disableHint := `set "isolation.enabled": false in config.json` + logger.WarnCF("isolation", "bubblewrap is required for Linux isolation", + map[string]any{ + "binary": "bwrap", + "install": hint, + "disable_isolation": disableHint, + "risk": "disabling isolation lets child processes run without Linux filesystem isolation", + }) + return fmt.Errorf( + "linux isolation requires bwrap and does not fall back automatically: %w; install bubblewrap with one of: %s; or disable isolation by setting %s; disabling isolation means child processes can run without Linux filesystem isolation and may access or modify more host files", + err, + hint, + disableHint, + ) + } + if cmd == nil || cmd.Path == "" || len(cmd.Args) == 0 { + return nil + } + + originalPath := cmd.Path + originalArgs := append([]string{}, cmd.Args...) + _, execDir, err := resolveLinuxWorkingDir(cmd.Dir, originalPath) + if err != nil { + return err + } + resolvedPath, err := resolveLinuxCommandPath(originalPath, execDir) + if err != nil { + return err + } + + // Start from the configured mount plan, then add only the executable, its + // resolved path, the effective working directory, and any absolute path + // arguments needed to preserve the original command semantics. + plan := BuildLinuxMountPlan(root, isolation.ExposePaths) + plan = ensureLinuxMountRule(plan, resolvedPath, resolvedPath, "ro") + plan = ensureLinuxMountRule(plan, filepath.Dir(resolvedPath), filepath.Dir(resolvedPath), "ro") + if resolved, resolveErr := filepath.EvalSymlinks(resolvedPath); resolveErr == nil && resolved != resolvedPath { + plan = ensureLinuxMountRule(plan, resolved, resolved, "ro") + plan = ensureLinuxMountRule(plan, filepath.Dir(resolved), filepath.Dir(resolved), "ro") + } + if execDir != "" { + plan = ensureLinuxMountRule(plan, execDir, execDir, "rw") + if resolved, resolveErr := filepath.EvalSymlinks(execDir); resolveErr == nil && resolved != execDir { + plan = ensureLinuxMountRule(plan, resolved, resolved, "rw") + } + } + plan = appendLinuxArgumentMounts(plan, originalArgs[1:]) + logger.DebugCF("isolation", "linux isolation mount plan", + map[string]any{ + "root": root, + "command": resolvedPath, + "working_dir": execDir, + "mounts": formatLinuxMountPlan(plan), + }) + bwrapArgs, err := buildLinuxBwrapArgs(originalPath, resolvedPath, originalArgs, execDir, plan) + if err != nil { + return err + } + + cmd.Path = bwrapPath + cmd.Args = bwrapArgs + cmd.Dir = "" + return nil +} + +func bwrapInstallHint() string { + return "apt install bubblewrap; dnf install bubblewrap; yum install bubblewrap; pacman -S bubblewrap; apk add bubblewrap" +} + +// formatLinuxMountPlan reshapes the internal plan for structured logging. +func formatLinuxMountPlan(plan []MountRule) []map[string]string { + formatted := make([]map[string]string, 0, len(plan)) + for _, rule := range plan { + formatted = append(formatted, map[string]string{ + "source": rule.Source, + "target": rule.Target, + "mode": rule.Mode, + }) + } + return formatted +} + +func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + return nil +} + +func cleanupPendingPlatformResources(cmd *exec.Cmd) { +} + +// buildLinuxBwrapArgs translates the mount plan into the bubblewrap command +// line that re-executes the original process inside the isolated mount view. +func buildLinuxBwrapArgs( + originalPath string, + resolvedPath string, + originalArgs []string, + execDir string, + plan []MountRule, +) ([]string, error) { + bwrapArgs := []string{ + "bwrap", + "--die-with-parent", + "--unshare-ipc", + "--proc", "/proc", + "--dev", "/dev", + } + for _, rule := range plan { + flag, err := linuxBindFlag(rule) + if err != nil { + return nil, err + } + bwrapArgs = append(bwrapArgs, flag, rule.Source, rule.Target) + } + if execDir != "" { + bwrapArgs = append(bwrapArgs, "--chdir", execDir) + } + execPath := originalPath + if isRelativeCommandPath(originalPath) { + execPath = resolvedPath + } + bwrapArgs = append(bwrapArgs, "--", execPath) + if len(originalArgs) > 1 { + bwrapArgs = append(bwrapArgs, originalArgs[1:]...) + } + return bwrapArgs, nil +} + +func resolveLinuxWorkingDir(originalDir, originalPath string) (string, string, error) { + if originalDir != "" { + resolved, err := filepath.Abs(originalDir) + if err != nil { + return "", "", fmt.Errorf("resolve command dir %s: %w", originalDir, err) + } + return resolved, resolved, nil + } + if !isRelativeCommandPath(originalPath) { + return "", "", nil + } + wd, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("resolve current working dir: %w", err) + } + return "", wd, nil +} + +func resolveLinuxCommandPath(originalPath, execDir string) (string, error) { + if filepath.IsAbs(originalPath) || !isRelativeCommandPath(originalPath) { + return filepath.Clean(originalPath), nil + } + base := execDir + if base == "" { + var err error + base, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve current working dir: %w", err) + } + } + return filepath.Clean(filepath.Join(base, originalPath)), nil +} + +func appendLinuxArgumentMounts(plan []MountRule, args []string) []MountRule { + for _, arg := range args { + path, ok := linuxArgumentPath(arg) + if !ok { + continue + } + clean := filepath.Clean(path) + if info, err := os.Stat(clean); err == nil { + mode := "ro" + if info.IsDir() { + mode = "rw" + } + plan = ensureLinuxMountRule(plan, clean, clean, mode) + if resolved, resolveErr := filepath.EvalSymlinks(clean); resolveErr == nil && resolved != clean { + plan = ensureLinuxMountRule(plan, resolved, resolved, mode) + } + continue + } else if !errors.Is(err, os.ErrNotExist) { + continue + } + parent := filepath.Dir(clean) + if parent == clean { + continue + } + if _, err := os.Stat(parent); err == nil { + plan = ensureLinuxMountRule(plan, parent, parent, "rw") + } + } + return plan +} + +func linuxArgumentPath(arg string) (string, bool) { + if filepath.IsAbs(arg) { + return arg, true + } + idx := strings.IndexRune(arg, '=') + if idx <= 0 || idx == len(arg)-1 { + return "", false + } + value := arg[idx+1:] + if !filepath.IsAbs(value) { + return "", false + } + return value, true +} + +func isRelativeCommandPath(path string) bool { + return !filepath.IsAbs(path) && strings.ContainsRune(path, filepath.Separator) +} + +// ensureLinuxMountRule appends a mount rule unless another rule already owns +// the same target path. +func ensureLinuxMountRule(plan []MountRule, source, target, mode string) []MountRule { + cleanSource := filepath.Clean(source) + cleanTarget := filepath.Clean(target) + for _, rule := range plan { + if filepath.Clean(rule.Target) == cleanTarget { + return plan + } + } + return append(plan, MountRule{Source: cleanSource, Target: cleanTarget, Mode: mode}) +} + +// linuxBindFlag selects the correct bubblewrap bind flag based on mount mode. +func linuxBindFlag(rule MountRule) (string, error) { + info, err := os.Stat(rule.Source) + if err != nil { + return "", fmt.Errorf("stat linux mount source %s: %w", rule.Source, err) + } + if !info.IsDir() { + if rule.Mode == "rw" { + return "--bind", nil + } + return "--ro-bind", nil + } + if rule.Mode == "rw" { + return "--bind", nil + } + return "--ro-bind", nil +} diff --git a/pkg/isolation/platform_linux_test.go b/pkg/isolation/platform_linux_test.go new file mode 100644 index 000000000..2dcca96ce --- /dev/null +++ b/pkg/isolation/platform_linux_test.go @@ -0,0 +1,148 @@ +//go:build linux + +package isolation + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestBuildLinuxBwrapArgs_IncludesNamespaceFlagsAndExec(t *testing.T) { + root := t.TempDir() + binaryDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binaryDir, 0o755); err != nil { + t.Fatal(err) + } + binaryPath := filepath.Join(binaryDir, "tool") + if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + plan := BuildLinuxMountPlan(root, []config.ExposePath{{Source: binaryDir, Target: binaryDir, Mode: "ro"}}) + args, err := buildLinuxBwrapArgs(binaryPath, binaryPath, []string{binaryPath, "--flag"}, root, plan) + if err != nil { + t.Fatalf("buildLinuxBwrapArgs() error = %v", err) + } + hasNet := false + hasIPC := false + hasExec := false + for i := range args { + switch args[i] { + case "--unshare-net": + hasNet = true + case "--unshare-ipc": + hasIPC = true + case "--": + if i+1 < len(args) && args[i+1] == binaryPath { + hasExec = true + } + } + } + if hasNet { + t.Fatalf("bwrap args should not unshare net by default: %v", args) + } + if !hasIPC || !hasExec { + t.Fatalf("bwrap args missing required items: %v", args) + } +} + +func TestResolveLinuxWorkingDir_ResolvesRelativeDir(t *testing.T) { + cwd := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { + if chdirErr := os.Chdir(previous); chdirErr != nil { + t.Fatalf("restore cwd: %v", chdirErr) + } + }() + if chdirErr := os.Chdir(cwd); chdirErr != nil { + t.Fatal(chdirErr) + } + + resolvedDir, execDir, err := resolveLinuxWorkingDir("./hooks", "./hook.sh") + if err != nil { + t.Fatalf("resolveLinuxWorkingDir() error = %v", err) + } + want := filepath.Join(cwd, "hooks") + if resolvedDir != want || execDir != want { + t.Fatalf("resolveLinuxWorkingDir() = (%q, %q), want (%q, %q)", resolvedDir, execDir, want, want) + } +} + +func TestResolveLinuxCommandPath_UsesExecDirForRelativeCommand(t *testing.T) { + execDir := filepath.Join(t.TempDir(), "hooks") + got, err := resolveLinuxCommandPath("./hook.sh", execDir) + if err != nil { + t.Fatalf("resolveLinuxCommandPath() error = %v", err) + } + want := filepath.Join(execDir, "hook.sh") + if got != want { + t.Fatalf("resolveLinuxCommandPath() = %q, want %q", got, want) + } +} + +func TestBuildLinuxBwrapArgs_UsesResolvedPathForRelativeCommand(t *testing.T) { + root := t.TempDir() + execDir := filepath.Join(root, "hooks") + if err := os.MkdirAll(execDir, 0o755); err != nil { + t.Fatal(err) + } + resolvedPath := filepath.Join(execDir, "hook.sh") + if err := os.WriteFile(resolvedPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + plan := []MountRule{ + {Source: execDir, Target: execDir, Mode: "rw"}, + {Source: resolvedPath, Target: resolvedPath, Mode: "ro"}, + } + args, err := buildLinuxBwrapArgs("./hook.sh", resolvedPath, []string{"./hook.sh"}, execDir, plan) + if err != nil { + t.Fatalf("buildLinuxBwrapArgs() error = %v", err) + } + hasExecDir := false + for _, arg := range args { + if arg == execDir { + hasExecDir = true + break + } + } + if !hasExecDir { + t.Fatalf("buildLinuxBwrapArgs() missing resolved chdir: %v", args) + } + for i := range args { + if args[i] == "--" { + if i+1 >= len(args) || args[i+1] != resolvedPath { + t.Fatalf("buildLinuxBwrapArgs() exec path = %v, want %q after --", args, resolvedPath) + } + return + } + } + t.Fatalf("buildLinuxBwrapArgs() missing exec delimiter: %v", args) +} + +func TestAppendLinuxArgumentMounts_AddsAbsoluteArgumentPaths(t *testing.T) { + root := t.TempDir() + input := filepath.Join(root, "input.txt") + if err := os.WriteFile(input, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + output := filepath.Join(root, "out", "result.txt") + if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { + t.Fatal(err) + } + + plan := appendLinuxArgumentMounts(nil, []string{input, "--output=" + output}) + if len(plan) != 2 { + t.Fatalf("appendLinuxArgumentMounts() len = %d, want 2", len(plan)) + } + if plan[0].Source != input || plan[0].Mode != "ro" { + t.Fatalf("appendLinuxArgumentMounts()[0] = %+v, want source=%q mode=ro", plan[0], input) + } + if plan[1].Source != filepath.Dir(output) || plan[1].Mode != "rw" { + t.Fatalf("appendLinuxArgumentMounts()[1] = %+v, want source=%q mode=rw", plan[1], filepath.Dir(output)) + } +} diff --git a/pkg/isolation/platform_other.go b/pkg/isolation/platform_other.go new file mode 100644 index 000000000..d8d06e2ec --- /dev/null +++ b/pkg/isolation/platform_other.go @@ -0,0 +1,22 @@ +//go:build !linux && !windows + +package isolation + +import ( + "os/exec" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + // Unsupported platforms currently keep the command unchanged. Callers rely on + // Preflight and higher-level checks to surface unsupported isolation modes. + return nil +} + +func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + return nil +} + +func cleanupPendingPlatformResources(cmd *exec.Cmd) { +} diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go new file mode 100644 index 000000000..9434976f7 --- /dev/null +++ b/pkg/isolation/platform_windows.go @@ -0,0 +1,217 @@ +//go:build windows + +package isolation + +import ( + "fmt" + "os/exec" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const disableMaxPrivilege = 0x1 + +// windowsProcessResources holds native handles that must live for the lifetime +// of an isolated child process. +type windowsProcessResources struct { + job windows.Handle + token windows.Token +} + +var ( + windowsProcessResourcesByPID sync.Map + windowsPendingResources sync.Map + advapi32 = windows.NewLazySystemDLL("advapi32.dll") + procCreateRestrictedToken = advapi32.NewProc("CreateRestrictedToken") +) + +func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + if !isolation.Enabled || cmd == nil { + return nil + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + rules := BuildWindowsAccessRules(root, isolation.ExposePaths) + logger.InfoCF("isolation", "windows isolation process constraints", + map[string]any{ + "root": root, + "command": cmd.Path, + "rules": formatWindowsAccessRules(rules), + "note": "Windows currently enforces restricted token, low integrity, and job object limits; expose_paths filesystem remapping is rejected during preflight", + }) + // Create the restricted token before the process starts so CreateProcess uses + // the reduced privilege set from the first instruction. + restrictedToken, err := createRestrictedPrimaryToken() + if err != nil { + return fmt.Errorf("create restricted primary token: %w", err) + } + cmd.SysProcAttr.CreationFlags |= windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB + cmd.SysProcAttr.Token = syscall.Token(restrictedToken) + windowsPendingResources.Store(cmd, windowsProcessResources{token: restrictedToken}) + return nil +} + +func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + if !isolation.Enabled || cmd == nil || cmd.Process == nil { + return nil + } + resourcesAny, _ := windowsPendingResources.LoadAndDelete(cmd) + resources, _ := resourcesAny.(windowsProcessResources) + // Job objects can only be attached after the process exists, so the Windows + // backend finishes isolation in this post-start hook. + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + if resources.token != 0 { + _ = resources.token.Close() + } + return fmt.Errorf("create windows job object: %w", err) + } + + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + _ = windows.CloseHandle(job) + if resources.token != 0 { + _ = resources.token.Close() + } + return fmt.Errorf("set windows job object info: %w", err) + } + + proc, err := windows.OpenProcess( + windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, + false, + uint32(cmd.Process.Pid), + ) + if err != nil { + _ = windows.CloseHandle(job) + if resources.token != 0 { + _ = resources.token.Close() + } + return fmt.Errorf("open process for job assignment: %w", err) + } + + if err := windows.AssignProcessToJobObject(job, proc); err != nil { + _ = windows.CloseHandle(proc) + _ = windows.CloseHandle(job) + if resources.token != 0 { + _ = resources.token.Close() + } + return fmt.Errorf("assign process to job object: %w", err) + } + + if resources.token != 0 { + _ = resources.token.Close() + } + resources.job = job + windowsProcessResourcesByPID.Store(cmd.Process.Pid, resources) + go reapWindowsProcessResources(cmd.Process.Pid, proc, job) + return nil +} + +func cleanupPendingPlatformResources(cmd *exec.Cmd) { + if cmd == nil { + return + } + resourcesAny, ok := windowsPendingResources.LoadAndDelete(cmd) + if !ok { + return + } + resources, _ := resourcesAny.(windowsProcessResources) + if resources.token != 0 { + _ = resources.token.Close() + } +} + +func reapWindowsProcessResources(pid int, proc windows.Handle, job windows.Handle) { + _, _ = windows.WaitForSingleObject(proc, windows.INFINITE) + _ = windows.CloseHandle(proc) + _ = windows.CloseHandle(job) + windowsProcessResourcesByPID.Delete(pid) +} + +// createRestrictedPrimaryToken duplicates the current process token, removes +// maximum privileges, and lowers integrity before it is assigned to a child. +func createRestrictedPrimaryToken() (windows.Token, error) { + var current windows.Token + if err := windows.OpenProcessToken( + windows.CurrentProcess(), + windows.TOKEN_DUPLICATE|windows.TOKEN_ASSIGN_PRIMARY|windows.TOKEN_QUERY|windows.TOKEN_ADJUST_DEFAULT, + ¤t, + ); err != nil { + return 0, err + } + defer current.Close() + + var restricted windows.Token + r1, _, e1 := procCreateRestrictedToken.Call( + uintptr(current), + uintptr(disableMaxPrivilege), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + uintptr(unsafe.Pointer(&restricted)), + ) + if r1 == 0 { + if e1 != nil && e1 != syscall.Errno(0) { + return 0, e1 + } + return 0, syscall.EINVAL + } + if err := setTokenLowIntegrity(restricted); err != nil { + _ = restricted.Close() + return 0, err + } + return restricted, nil +} + +// setTokenLowIntegrity lowers the token integrity level so writes to higher +// integrity locations are blocked by the OS. +func setTokenLowIntegrity(token windows.Token) error { + lowSID, err := windows.CreateWellKnownSid(windows.WinLowLabelSid) + if err != nil { + return fmt.Errorf("create low integrity sid: %w", err) + } + tml := windows.Tokenmandatorylabel{ + Label: windows.SIDAndAttributes{ + Sid: lowSID, + Attributes: windows.SE_GROUP_INTEGRITY, + }, + } + if err := windows.SetTokenInformation( + token, + windows.TokenIntegrityLevel, + (*byte)(unsafe.Pointer(&tml)), + tml.Size(), + ); err != nil { + return fmt.Errorf("set token low integrity: %w", err) + } + return nil +} + +// formatWindowsAccessRules reshapes the internal rules for structured logging. +func formatWindowsAccessRules(rules []AccessRule) []map[string]string { + formatted := make([]map[string]string, 0, len(rules)) + for _, rule := range rules { + formatted = append(formatted, map[string]string{ + "path": rule.Path, + "mode": rule.Mode, + }) + } + return formatted +} diff --git a/pkg/isolation/runtime.go b/pkg/isolation/runtime.go new file mode 100644 index 000000000..b2de98b88 --- /dev/null +++ b/pkg/isolation/runtime.go @@ -0,0 +1,443 @@ +package isolation + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg" + "github.com/sipeed/picoclaw/pkg/config" +) + +// MountRule describes a source-to-target mount exposed inside the Linux +// isolation view. +type MountRule struct { + Source string + Target string + Mode string +} + +// AccessRule describes the effective Windows-side access rule for a host path. +type AccessRule struct { + Path string + Mode string +} + +// UserEnv contains the redirected per-instance user directories injected into +// isolated child processes. +type UserEnv struct { + Home string + Tmp string + Config string + Cache string + State string + AppData string + LocalAppData string +} + +var ( + isolationMu sync.RWMutex + currentIsolation = config.DefaultConfig().Isolation +) + +// Configure updates the process-wide isolation state used by subsequent child +// process launches. +func Configure(cfg *config.Config) { + isolationMu.Lock() + defer isolationMu.Unlock() + if cfg == nil { + defaults := config.DefaultConfig() + currentIsolation = defaults.Isolation + return + } + currentIsolation = cfg.Isolation +} + +// CurrentConfig returns the currently active isolation settings. +func CurrentConfig() config.IsolationConfig { + isolationMu.RLock() + defer isolationMu.RUnlock() + return currentIsolation +} + +// ResolveInstanceRoot resolves the instance root used to build the isolated +// filesystem and redirected user environment. +func ResolveInstanceRoot() (string, error) { + root := filepath.Clean(config.GetHome()) + if root == "." { + return "", fmt.Errorf("instance root resolved to current directory") + } + return root, nil +} + +// PrepareInstanceRoot creates the directories required by the isolation runtime. +func PrepareInstanceRoot(root string) error { + for _, dir := range InstanceDirs(root) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("prepare instance dir %s: %w", dir, err) + } + } + return nil +} + +// InstanceDirs returns the directories that must exist under the instance root +// for isolation-aware child processes. +func InstanceDirs(root string) []string { + dirs := []string{ + root, + filepath.Join(root, "skills"), + filepath.Join(root, "logs"), + filepath.Join(root, "cache"), + filepath.Join(root, "state"), + filepath.Join(root, "runtime-user-env"), + filepath.Join(root, "runtime-user-env", "home"), + filepath.Join(root, "runtime-user-env", "tmp"), + filepath.Join(root, "runtime-user-env", "config"), + filepath.Join(root, "runtime-user-env", "cache"), + filepath.Join(root, "runtime-user-env", "state"), + } + dirs = append(dirs, filepath.Join(root, pkg.WorkspaceName)) + if runtime.GOOS == "windows" { + dirs = append(dirs, + filepath.Join(root, "runtime-user-env", "AppData", "Roaming"), + filepath.Join(root, "runtime-user-env", "AppData", "Local"), + ) + } + return dirs +} + +// ResolveUserEnv derives the redirected user directories rooted under the +// instance runtime area. +func ResolveUserEnv(root string) UserEnv { + base := filepath.Join(root, "runtime-user-env") + return UserEnv{ + Home: filepath.Join(base, "home"), + Tmp: filepath.Join(base, "tmp"), + Config: filepath.Join(base, "config"), + Cache: filepath.Join(base, "cache"), + State: filepath.Join(base, "state"), + AppData: filepath.Join(base, "AppData", "Roaming"), + LocalAppData: filepath.Join(base, "AppData", "Local"), + } +} + +// ApplyUserEnv rewrites the child process environment so home, temp, and +// platform-specific user-data directories point into the instance root. +func ApplyUserEnv(cmd *exec.Cmd, root string) { + userEnv := ResolveUserEnv(root) + envMap := make(map[string]string) + for _, item := range cmd.Environ() { + if idx := strings.IndexRune(item, '='); idx > 0 { + envMap[item[:idx]] = item[idx+1:] + } + } + + if runtime.GOOS == "windows" { + envMap["USERPROFILE"] = userEnv.Home + envMap["HOME"] = userEnv.Home + envMap["TEMP"] = userEnv.Tmp + envMap["TMP"] = userEnv.Tmp + envMap["APPDATA"] = userEnv.AppData + envMap["LOCALAPPDATA"] = userEnv.LocalAppData + } else { + envMap["HOME"] = userEnv.Home + envMap["TMPDIR"] = userEnv.Tmp + envMap["XDG_CONFIG_HOME"] = userEnv.Config + envMap["XDG_CACHE_HOME"] = userEnv.Cache + envMap["XDG_STATE_HOME"] = userEnv.State + } + + env := make([]string, 0, len(envMap)) + for k, v := range envMap { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + cmd.Env = env +} + +// ValidateExposePaths verifies the user-supplied path exposure rules before a +// child process is started. +func ValidateExposePaths(items []config.ExposePath) error { + seen := map[string]struct{}{} + for _, item := range items { + if item.Source == "" { + return fmt.Errorf("source is required") + } + if item.Mode != "ro" && item.Mode != "rw" { + return fmt.Errorf("invalid expose_paths mode: %s", item.Mode) + } + + source := filepath.Clean(item.Source) + target := item.Target + if target == "" { + target = source + } + target = filepath.Clean(target) + + if !filepath.IsAbs(source) || !filepath.IsAbs(target) { + return fmt.Errorf("source and target must be absolute paths") + } + if _, ok := seen[target]; ok { + return fmt.Errorf("duplicate expose_path target: %s", target) + } + seen[target] = struct{}{} + } + return nil +} + +// NormalizeExposePath fills implicit defaults and cleans path values so merge +// and validation logic can work with canonical paths. +func NormalizeExposePath(item config.ExposePath) config.ExposePath { + source := filepath.Clean(item.Source) + target := item.Target + if target == "" { + target = source + } + return config.ExposePath{ + Source: source, + Target: filepath.Clean(target), + Mode: item.Mode, + } +} + +// DefaultExposePaths returns the minimum built-in host paths required for the +// current platform to run isolated child processes. +func DefaultExposePaths(root string) []config.ExposePath { + items := []config.ExposePath{{ + Source: root, + Target: root, + Mode: "rw", + }} + if runtime.GOOS == "linux" { + items = append(items, defaultLinuxSystemExposePaths()...) + } + return items +} + +func defaultLinuxSystemExposePaths() []config.ExposePath { + return existingExposePaths([]config.ExposePath{ + {Source: "/usr", Target: "/usr", Mode: "ro"}, + {Source: "/bin", Target: "/bin", Mode: "ro"}, + {Source: "/lib", Target: "/lib", Mode: "ro"}, + {Source: "/lib64", Target: "/lib64", Mode: "ro"}, + {Source: "/etc/resolv.conf", Target: "/etc/resolv.conf", Mode: "ro"}, + {Source: "/etc/hosts", Target: "/etc/hosts", Mode: "ro"}, + {Source: "/etc/nsswitch.conf", Target: "/etc/nsswitch.conf", Mode: "ro"}, + {Source: "/etc/passwd", Target: "/etc/passwd", Mode: "ro"}, + {Source: "/etc/group", Target: "/etc/group", Mode: "ro"}, + {Source: "/etc/ssl", Target: "/etc/ssl", Mode: "ro"}, + {Source: "/etc/pki", Target: "/etc/pki", Mode: "ro"}, + {Source: "/etc/ca-certificates", Target: "/etc/ca-certificates", Mode: "ro"}, + {Source: "/usr/share/ca-certificates", Target: "/usr/share/ca-certificates", Mode: "ro"}, + {Source: "/usr/local/share/ca-certificates", Target: "/usr/local/share/ca-certificates", Mode: "ro"}, + {Source: "/etc/alternatives", Target: "/etc/alternatives", Mode: "ro"}, + {Source: "/usr/share/zoneinfo", Target: "/usr/share/zoneinfo", Mode: "ro"}, + {Source: "/etc/localtime", Target: "/etc/localtime", Mode: "ro"}, + }) +} + +// existingExposePaths keeps only the builtin host paths that exist on the +// current machine so Linux isolation does not fail on distro-specific paths. +func existingExposePaths(items []config.ExposePath) []config.ExposePath { + filtered := make([]config.ExposePath, 0, len(items)) + for _, item := range items { + if _, err := os.Stat(item.Source); err == nil { + filtered = append(filtered, item) + } + } + return filtered +} + +// MergeExposePaths merges built-in rules with user overrides. Rules are keyed +// by target path so later entries replace earlier ones for the same target. +func MergeExposePaths(defaults []config.ExposePath, overrides []config.ExposePath) []config.ExposePath { + merged := make([]config.ExposePath, 0, len(defaults)+len(overrides)) + indexByTarget := make(map[string]int, len(defaults)+len(overrides)) + appendOrReplace := func(item config.ExposePath) { + normalized := NormalizeExposePath(item) + if idx, ok := indexByTarget[normalized.Target]; ok { + merged[idx] = normalized + return + } + indexByTarget[normalized.Target] = len(merged) + merged = append(merged, normalized) + } + for _, item := range defaults { + appendOrReplace(item) + } + for _, item := range overrides { + appendOrReplace(item) + } + return merged +} + +// BuildLinuxMountPlan converts the merged expose-path configuration into the +// mount rules consumed by the Linux bubblewrap backend. +func BuildLinuxMountPlan(root string, overrides []config.ExposePath) []MountRule { + merged := MergeExposePaths(DefaultExposePaths(root), overrides) + plan := make([]MountRule, 0, len(merged)) + for _, item := range merged { + plan = append(plan, MountRule{Source: item.Source, Target: item.Target, Mode: item.Mode}) + } + return plan +} + +// BuildWindowsAccessRules derives the host-path access policy used by the +// Windows restricted-token backend. +func BuildWindowsAccessRules(root string, overrides []config.ExposePath) []AccessRule { + merged := MergeExposePaths(nil, overrides) + rules := make([]AccessRule, 0, len(merged)+1) + rules = append(rules, AccessRule{Path: root, Mode: "rw"}) + for _, item := range merged { + rules = append(rules, AccessRule{Path: item.Source, Mode: item.Mode}) + } + return rules +} + +func validateWindowsExposePaths(items []config.ExposePath) error { + if len(items) == 0 { + return nil + } + return fmt.Errorf("windows isolation does not yet support expose_paths filesystem rules") +} + +// IsSupported reports whether the current platform has an implemented isolation +// backend. +func IsSupported() bool { + return isSupportedOn(runtime.GOOS) +} + +func isSupportedOn(goos string) bool { + switch goos { + case "linux", "windows": + return true + default: + return false + } +} + +// Preflight validates the configured isolation state and prepares the instance +// runtime directories before any child process is launched. +func Preflight() error { + isolation := CurrentConfig() + if !isolation.Enabled { + return nil + } + if !IsSupported() { + return fmt.Errorf("subprocess isolation is not supported on %s", runtime.GOOS) + } + root, err := ResolveInstanceRoot() + if err != nil { + return err + } + if err := PrepareInstanceRoot(root); err != nil { + return err + } + if err := ValidateExposePaths(isolation.ExposePaths); err != nil { + return err + } + if runtime.GOOS == "linux" { + for _, rule := range BuildLinuxMountPlan(root, isolation.ExposePaths) { + if rule.Source == "" || rule.Target == "" { + return fmt.Errorf("invalid linux mount rule") + } + } + } + if runtime.GOOS == "windows" { + if err := validateWindowsExposePaths(isolation.ExposePaths); err != nil { + return err + } + for _, rule := range BuildWindowsAccessRules(root, isolation.ExposePaths) { + if rule.Path == "" { + return fmt.Errorf("invalid windows access rule") + } + } + } + return nil +} + +// Start prepares isolation for the command, starts it, and applies any +// post-start platform hooks required by the active backend. +func Start(cmd *exec.Cmd) error { + if err := PrepareCommand(cmd); err != nil { + return err + } + if err := cmd.Start(); err != nil { + cleanupPendingPlatformResources(cmd) + return err + } + isolation := CurrentConfig() + root := "" + if isolation.Enabled { + var err error + root, err = ResolveInstanceRoot() + if err != nil { + terminateStartedCommand(cmd) + return err + } + } + if err := postStartPlatformIsolation(cmd, isolation, root); err != nil { + terminateStartedCommand(cmd) + return err + } + return nil +} + +// Run is the Start-and-Wait helper that keeps the same isolation behavior as +// Start while returning the command's final exit status. +func Run(cmd *exec.Cmd) error { + if err := PrepareCommand(cmd); err != nil { + return err + } + if err := cmd.Start(); err != nil { + cleanupPendingPlatformResources(cmd) + return err + } + isolation := CurrentConfig() + root := "" + if isolation.Enabled { + var err error + root, err = ResolveInstanceRoot() + if err != nil { + terminateStartedCommand(cmd) + return err + } + } + if err := postStartPlatformIsolation(cmd, isolation, root); err != nil { + terminateStartedCommand(cmd) + return err + } + return cmd.Wait() +} + +func terminateStartedCommand(cmd *exec.Cmd) { + cleanupPendingPlatformResources(cmd) + if cmd == nil || cmd.Process == nil { + return + } + _ = cmd.Process.Kill() + _ = cmd.Wait() +} + +// PrepareCommand mutates the command in-place so it inherits the configured +// isolated environment before being started by the caller. +func PrepareCommand(cmd *exec.Cmd) error { + isolation := CurrentConfig() + if err := Preflight(); err != nil { + return err + } + if isolation.Enabled { + root, err := ResolveInstanceRoot() + if err != nil { + return err + } + ApplyUserEnv(cmd, root) + if err := applyPlatformIsolation(cmd, isolation, root); err != nil { + return err + } + } + return nil +} diff --git a/pkg/isolation/runtime_test.go b/pkg/isolation/runtime_test.go new file mode 100644 index 000000000..213c4b065 --- /dev/null +++ b/pkg/isolation/runtime_test.go @@ -0,0 +1,245 @@ +package isolation + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/sipeed/picoclaw/pkg" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestResolveInstanceRoot_UsesPicoclawHome(t *testing.T) { + t.Setenv(config.EnvHome, "/custom/picoclaw/home") + root, err := ResolveInstanceRoot() + if err != nil { + t.Fatalf("ResolveInstanceRoot() error = %v", err) + } + if root != "/custom/picoclaw/home" { + t.Fatalf("ResolveInstanceRoot() = %q, want %q", root, "/custom/picoclaw/home") + } +} + +func TestPrepareInstanceRoot_CreatesDirectories(t *testing.T) { + root := filepath.Join(t.TempDir(), "instance") + if err := PrepareInstanceRoot(root); err != nil { + t.Fatalf("PrepareInstanceRoot() error = %v", err) + } + for _, dir := range InstanceDirs(root) { + if info, err := os.Stat(dir); err != nil { + t.Fatalf("os.Stat(%q): %v", dir, err) + } else if !info.IsDir() { + t.Fatalf("%q is not a directory", dir) + } + } +} + +func TestInstanceDirs_UsesInstanceWorkspaceNotGlobalState(t *testing.T) { + root := filepath.Join(t.TempDir(), "instance") + cfg := config.DefaultConfig() + cfg.Isolation.Enabled = true + cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "external-workspace") + Configure(cfg) + t.Cleanup(func() { Configure(config.DefaultConfig()) }) + + dirs := InstanceDirs(root) + wantWorkspace := filepath.Join(root, pkg.WorkspaceName) + found := false + for _, dir := range dirs { + if dir == wantWorkspace { + found = true + } + if dir == cfg.WorkspacePath() { + t.Fatalf("InstanceDirs() should not depend on process-wide workspace state: %q", dir) + } + } + if !found { + t.Fatalf("InstanceDirs() missing instance workspace dir %q", wantWorkspace) + } +} + +func TestIsSupportedOn(t *testing.T) { + tests := []struct { + goos string + want bool + }{ + {goos: "linux", want: true}, + {goos: "windows", want: true}, + {goos: "darwin", want: false}, + {goos: "freebsd", want: false}, + } + for _, tt := range tests { + if got := isSupportedOn(tt.goos); got != tt.want { + t.Fatalf("isSupportedOn(%q) = %v, want %v", tt.goos, got, tt.want) + } + } +} + +func TestValidateExposePaths(t *testing.T) { + err := ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}}) + if err != nil { + t.Fatalf("ValidateExposePaths() error = %v", err) + } + + err = ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "bad"}}) + if err == nil { + t.Fatal("ValidateExposePaths() expected invalid mode error") + } + + err = ValidateExposePaths( + []config.ExposePath{ + {Source: "/src", Target: "/dst", Mode: "ro"}, + {Source: "/other", Target: "/dst", Mode: "rw"}, + }, + ) + if err == nil { + t.Fatal("ValidateExposePaths() expected duplicate target error") + } +} + +func TestMergeExposePaths_OverrideByTarget(t *testing.T) { + merged := MergeExposePaths( + []config.ExposePath{{Source: "/src-a", Target: "/dst", Mode: "ro"}}, + []config.ExposePath{{Source: "/src-b", Target: "/dst", Mode: "rw"}}, + ) + if len(merged) != 1 { + t.Fatalf("MergeExposePaths len = %d, want 1", len(merged)) + } + if got := merged[0]; got.Source != "/src-b" || got.Target != "/dst" || got.Mode != "rw" { + t.Fatalf("merged[0] = %+v, want source=/src-b target=/dst mode=rw", got) + } +} + +func TestBuildLinuxMountPlan(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-only default mount set") + } + plan := BuildLinuxMountPlan("/rootdir", []config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}}) + if len(plan) == 0 { + t.Fatal("BuildLinuxMountPlan returned empty plan") + } + foundRoot := false + foundOverride := false + for _, rule := range plan { + if rule.Source == "/rootdir" && rule.Target == "/rootdir" && rule.Mode == "rw" { + foundRoot = true + } + if rule.Source == "/src" && rule.Target == "/dst" && rule.Mode == "ro" { + foundOverride = true + } + } + if !foundRoot { + t.Fatal("BuildLinuxMountPlan missing root mapping") + } + if !foundOverride { + t.Fatal("BuildLinuxMountPlan missing override mapping") + } +} + +func TestBuildWindowsAccessRules(t *testing.T) { + rules := BuildWindowsAccessRules( + `C:\picoclaw`, + []config.ExposePath{{Source: `D:\data`, Target: `C:\mapped`, Mode: "ro"}}, + ) + if len(rules) == 0 { + t.Fatal("BuildWindowsAccessRules returned empty rules") + } + foundRoot := false + foundOverride := false + for _, rule := range rules { + if rule.Path == `C:\picoclaw` && rule.Mode == "rw" { + foundRoot = true + } + if rule.Path == `D:\data` && rule.Mode == "ro" { + foundOverride = true + } + } + if !foundRoot { + t.Fatal("BuildWindowsAccessRules missing root rule") + } + if !foundOverride { + t.Fatal("BuildWindowsAccessRules missing override rule") + } +} + +func TestValidateWindowsExposePaths(t *testing.T) { + if err := validateWindowsExposePaths(nil); err != nil { + t.Fatalf("validateWindowsExposePaths(nil) error = %v", err) + } + err := validateWindowsExposePaths([]config.ExposePath{{Source: `D:\data`, Target: `D:\data`, Mode: "ro"}}) + if err == nil { + t.Fatal("validateWindowsExposePaths() expected error for expose_paths") + } +} + +func TestDefaultLinuxSystemExposePaths(t *testing.T) { + paths := defaultLinuxSystemExposePaths() + needed := map[string]bool{} + for _, path := range []string{"/etc/hosts", "/etc/nsswitch.conf", "/etc/ssl", "/usr/share/zoneinfo", "/etc/localtime"} { + if _, err := os.Stat(path); err == nil { + needed[path] = false + } + } + for _, item := range paths { + if _, ok := needed[item.Source]; ok { + needed[item.Source] = true + } + } + for path, found := range needed { + if !found { + t.Fatalf("defaultLinuxSystemExposePaths missing %s", path) + } + } +} + +func TestExistingExposePaths_SkipsMissingPaths(t *testing.T) { + existing := filepath.Join(t.TempDir(), "existing") + if err := os.MkdirAll(existing, 0o755); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + filtered := existingExposePaths([]config.ExposePath{ + {Source: existing, Target: existing, Mode: "ro"}, + {Source: filepath.Join(t.TempDir(), "missing"), Target: "/missing", Mode: "ro"}, + }) + if len(filtered) != 1 { + t.Fatalf("existingExposePaths() len = %d, want 1", len(filtered)) + } + if got := filtered[0]; got.Source != existing { + t.Fatalf("existingExposePaths()[0] = %+v, want source=%q", got, existing) + } +} + +func TestPrepareCommand_AppliesUserEnv(t *testing.T) { + t.Setenv(config.EnvHome, filepath.Join(t.TempDir(), "home")) + if runtime.GOOS == "linux" { + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + fakeBwrap := filepath.Join(binDir, "bwrap") + if err := os.WriteFile(fakeBwrap, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + } + cfg := config.DefaultConfig() + cfg.Isolation.Enabled = true + Configure(cfg) + t.Cleanup(func() { Configure(config.DefaultConfig()) }) + cmd := exec.Command("sh", "-c", "true") + if err := PrepareCommand(cmd); err != nil { + t.Fatalf("PrepareCommand() error = %v", err) + } + hasHome := false + for _, env := range cmd.Env { + if len(env) > 5 && env[:5] == "HOME=" { + hasHome = true + break + } + } + if runtime.GOOS != "windows" && !hasHome { + t.Fatal("PrepareCommand() did not inject HOME") + } +} diff --git a/pkg/mcp/isolated_command_transport.go b/pkg/mcp/isolated_command_transport.go new file mode 100644 index 000000000..f54b4af8b --- /dev/null +++ b/pkg/mcp/isolated_command_transport.go @@ -0,0 +1,226 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "sync" + "syscall" + "time" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/sipeed/picoclaw/pkg/isolation" +) + +var isolatedCommandTerminateDuration = 5 * time.Second + +// isolatedCommandTransport mirrors the SDK command transport but routes +// process startup through pkg/isolation so Windows post-start hooks run too. +type isolatedCommandTransport struct { + Command *exec.Cmd + TerminateDuration time.Duration +} + +func (t *isolatedCommandTransport) Connect(ctx context.Context) (sdkmcp.Connection, error) { + stdout, err := t.Command.StdoutPipe() + if err != nil { + return nil, err + } + stdout = io.NopCloser(stdout) + stdin, err := t.Command.StdinPipe() + if err != nil { + return nil, err + } + if err := isolation.Start(t.Command); err != nil { + return nil, err + } + td := t.TerminateDuration + if td <= 0 { + td = isolatedCommandTerminateDuration + } + return newIsolatedIOConn(&isolatedPipeRWC{cmd: t.Command, stdout: stdout, stdin: stdin, terminateDuration: td}), nil +} + +type isolatedPipeRWC struct { + cmd *exec.Cmd + stdout io.ReadCloser + stdin io.WriteCloser + terminateDuration time.Duration +} + +func (s *isolatedPipeRWC) Read(p []byte) (n int, err error) { + return s.stdout.Read(p) +} + +func (s *isolatedPipeRWC) Write(p []byte) (n int, err error) { + return s.stdin.Write(p) +} + +func (s *isolatedPipeRWC) Close() error { + if err := s.stdin.Close(); err != nil { + return fmt.Errorf("closing stdin: %v", err) + } + resChan := make(chan error, 1) + go func() { + resChan <- s.cmd.Wait() + }() + wait := func() (error, bool) { + select { + case err := <-resChan: + return err, true + case <-time.After(s.terminateDuration): + } + return nil, false + } + if err, ok := wait(); ok { + return err + } + if err := s.cmd.Process.Signal(syscall.SIGTERM); err == nil { + if err, ok := wait(); ok { + return err + } + } + if err := s.cmd.Process.Kill(); err != nil { + return err + } + if err, ok := wait(); ok { + return err + } + return fmt.Errorf("unresponsive subprocess") +} + +type isolatedIOConn struct { + writeMu sync.Mutex + rwc io.ReadWriteCloser + incoming <-chan isolatedMsgOrErr + queue []jsonrpc.Message + closeOnce sync.Once + closed chan struct{} + closeErr error +} + +type isolatedMsgOrErr struct { + msg json.RawMessage + err error +} + +func newIsolatedIOConn(rwc io.ReadWriteCloser) *isolatedIOConn { + incoming := make(chan isolatedMsgOrErr) + closed := make(chan struct{}) + go func() { + dec := json.NewDecoder(rwc) + for { + var raw json.RawMessage + err := dec.Decode(&raw) + if err == nil { + var tr [1]byte + if n, readErr := dec.Buffered().Read(tr[:]); n > 0 { + if tr[0] != '\n' && tr[0] != '\r' { + err = fmt.Errorf("invalid trailing data at the end of stream") + } + } else if readErr != nil && readErr != io.EOF { + err = readErr + } + } + select { + case incoming <- isolatedMsgOrErr{msg: raw, err: err}: + case <-closed: + return + } + if err != nil { + return + } + } + }() + return &isolatedIOConn{rwc: rwc, incoming: incoming, closed: closed} +} + +func (c *isolatedIOConn) SessionID() string { return "" } + +func (c *isolatedIOConn) Read(ctx context.Context) (jsonrpc.Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + if len(c.queue) > 0 { + next := c.queue[0] + c.queue = c.queue[1:] + return next, nil + } + var raw json.RawMessage + select { + case <-ctx.Done(): + return nil, ctx.Err() + case v := <-c.incoming: + if v.err != nil { + return nil, v.err + } + raw = v.msg + case <-c.closed: + return nil, io.EOF + } + msgs, err := readIsolatedBatch(raw) + if err != nil { + return nil, err + } + c.queue = msgs[1:] + return msgs[0], nil +} + +func readIsolatedBatch(data []byte) ([]jsonrpc.Message, error) { + var rawBatch []json.RawMessage + if err := json.Unmarshal(data, &rawBatch); err == nil { + if len(rawBatch) == 0 { + return nil, fmt.Errorf("empty batch") + } + msgs := make([]jsonrpc.Message, 0, len(rawBatch)) + for _, raw := range rawBatch { + msg, err := jsonrpc.DecodeMessage(raw) + if err != nil { + return nil, err + } + msgs = append(msgs, msg) + } + return msgs, nil + } + msg, err := jsonrpc.DecodeMessage(data) + if err != nil { + return nil, err + } + return []jsonrpc.Message{msg}, nil +} + +func (c *isolatedIOConn) Write(ctx context.Context, msg jsonrpc.Message) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + data, err := jsonrpc.EncodeMessage(msg) + if err != nil { + return fmt.Errorf("marshaling message: %v", err) + } + data = append(data, '\n') + _, err = c.rwc.Write(data) + return err +} + +func (c *isolatedIOConn) Close() error { + c.closeOnce.Do(func() { + c.closeErr = c.rwc.Close() + close(c.closed) + }) + return c.closeErr +} + +var ( + _ sdkmcp.Transport = (*isolatedCommandTransport)(nil) + _ sdkmcp.Connection = (*isolatedIOConn)(nil) +) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 323df0312..f589f82a9 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -365,8 +365,7 @@ func (m *Manager) ConnectServer( env = append(env, fmt.Sprintf("%s=%s", k, v)) } cmd.Env = env - - transport = &mcp.CommandTransport{Command: cmd} + transport = &isolatedCommandTransport{Command: cmd} default: return fmt.Errorf( "unsupported transport type: %s (supported: stdio, sse, http)", diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 40b581490..c3d98c555 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -7,6 +7,8 @@ import ( "fmt" "os/exec" "strings" + + "github.com/sipeed/picoclaw/pkg/isolation" ) // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. @@ -49,7 +51,9 @@ func (p *ClaudeCliProvider) Chat( cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { + // Execute the CLI through the shared isolation wrapper so external provider + // processes honor the configured isolation policy. + if err := isolation.Run(cmd); err != nil { stderrStr := strings.TrimSpace(stderr.String()) stdoutStr := strings.TrimSpace(stdout.String()) switch { diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 13f53ad9e..a9c8b692a 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -8,6 +8,8 @@ import ( "fmt" "os/exec" "strings" + + "github.com/sipeed/picoclaw/pkg/isolation" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. @@ -56,7 +58,9 @@ func (p *CodexCliProvider) Chat( cmd.Stdout = &stdout cmd.Stderr = &stderr - err := cmd.Run() + // Execute the CLI through the shared isolation wrapper so external provider + // processes honor the configured isolation policy. + err := isolation.Run(cmd) // Parse JSONL from stdout even if exit code is non-zero, // because codex writes diagnostic noise to stderr (e.g. rollout errors) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index d2971f3f8..a570ac9ec 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -20,6 +20,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/isolation" ) var ( @@ -120,7 +121,7 @@ func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regex func NewExecToolWithConfig( workingDir string, restrict bool, - config *config.Config, + cfg *config.Config, allowPaths ...[]*regexp.Regexp, ) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) @@ -131,8 +132,8 @@ func NewExecToolWithConfig( allowedPathPatterns = allowPaths[0] } - if config != nil { - execConfig := config.Tools.Exec + if cfg != nil { + execConfig := cfg.Tools.Exec enableDenyPatterns := execConfig.EnableDenyPatterns allowRemote = execConfig.AllowRemote if enableDenyPatterns { @@ -163,8 +164,8 @@ func NewExecToolWithConfig( } var timeout time.Duration - if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { - timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second + if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second } return &ExecTool{ @@ -378,7 +379,9 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { + // Route shell execution through the shared isolation entry point so exec tool + // subprocesses receive the same isolation policy as other integrations. + if err := isolation.Start(cmd); err != nil { return ErrorResult(fmt.Sprintf("failed to start command: %v", err)) } @@ -521,7 +524,9 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn session.stdinWriter = stdinWriter } - if err := cmd.Start(); err != nil { + // Background sessions use the same startup path so isolation stays consistent + // with synchronous exec runs. + if err := isolation.Start(cmd); err != nil { if session.ptyMaster != nil { session.ptyMaster.Close() }