diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go index 027cce165..09845a504 100644 --- a/pkg/agent/hook_process.go +++ b/pkg/agent/hook_process.go @@ -12,8 +12,8 @@ import ( "sync/atomic" "time" + "github.com/sipeed/picoclaw/pkg/isolation" "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/namespace" ) const ( @@ -121,7 +121,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 := namespace.Start(cmd); 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/instance.go b/pkg/agent/instance.go index 514c05a3a..5a6b210e3 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -9,10 +9,10 @@ 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" - "github.com/sipeed/picoclaw/pkg/namespace" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" @@ -62,7 +62,9 @@ func NewAgentInstance( provider providers.LLMProvider, ) *AgentInstance { if cfg != nil { - namespace.Configure(cfg) + // 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) diff --git a/pkg/config/config.go b/pkg/config/config.go index 2f03fc268..ada795d59 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,7 +24,7 @@ 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 Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"` @@ -46,11 +46,15 @@ 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. type ExposePath struct { Source string `json:"source"` Target string `json:"target,omitempty"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 650afaf8b..ea8e43464 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -17,6 +17,8 @@ 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, }, diff --git a/pkg/isolation/README.md b/pkg/isolation/README.md new file mode 100644 index 000000000..ee0461e33 --- /dev/null +++ b/pkg/isolation/README.md @@ -0,0 +1,234 @@ +# `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`. + +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 currently keeps `target` for config shape, but access control is still based on `source`. + +## 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 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 + +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..c61a3b5fa --- /dev/null +++ b/pkg/isolation/README_CN.md @@ -0,0 +1,234 @@ +# `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` 时生效。 + +示例: + +```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 当前保留 `target` 结构,但权限控制仍以 `source` 为准。 + +## 实例根与目录 + +实例根遵循 `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 后端当前使用: + +- 受限 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/namespace/platform_linux.go b/pkg/isolation/platform_linux.go similarity index 79% rename from pkg/namespace/platform_linux.go rename to pkg/isolation/platform_linux.go index 290ab59cc..1f5d99235 100644 --- a/pkg/namespace/platform_linux.go +++ b/pkg/isolation/platform_linux.go @@ -1,6 +1,6 @@ //go:build linux -package namespace +package isolation import ( "fmt" @@ -16,18 +16,20 @@ func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, roo 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("namespace", "bubblewrap is required for Linux isolation", + 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 namespace filesystem isolation", + "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 namespace filesystem isolation and may access or modify more host files", err, hint, disableHint) + 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 @@ -37,6 +39,9 @@ func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, roo originalArgs := append([]string{}, cmd.Args...) originalDir := cmd.Dir + // Start from the configured mount plan, then add the executable, its resolved + // path, the working directory, and any absolute path arguments the process may + // need at runtime. plan := BuildLinuxMountPlan(root, isolation.ExposePaths) plan = ensureLinuxMountRule(plan, originalPath, originalPath, "ro") plan = ensureLinuxMountRule(plan, filepath.Dir(originalPath), filepath.Dir(originalPath), "ro") @@ -55,7 +60,7 @@ func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, roo plan = ensureLinuxPathForArgument(plan, arg) } } - logger.DebugCF("namespace", "linux isolation mount plan", + logger.DebugCF("isolation", "linux isolation mount plan", map[string]any{ "root": root, "command": originalPath, @@ -77,6 +82,7 @@ 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 { @@ -93,6 +99,8 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, return nil } +// 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, originalArgs []string, originalDir string, plan []MountRule) ([]string, error) { bwrapArgs := []string{ "bwrap", @@ -118,6 +126,8 @@ func buildLinuxBwrapArgs(originalPath string, originalArgs []string, originalDir return bwrapArgs, nil } +// 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) @@ -129,6 +139,7 @@ func ensureLinuxMountRule(plan []MountRule, source, target, mode string) []Mount 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 { @@ -146,6 +157,9 @@ func linuxBindFlag(rule MountRule) (string, error) { return "--ro-bind", nil } +// ensureLinuxPathForArgument exposes absolute-path arguments conservatively so +// common CLI flags that point at files or directories keep working in the +// isolated filesystem view. func ensureLinuxPathForArgument(plan []MountRule, arg string) []MountRule { clean := filepath.Clean(arg) if info, err := os.Stat(clean); err == nil { diff --git a/pkg/namespace/platform_linux_test.go b/pkg/isolation/platform_linux_test.go similarity index 98% rename from pkg/namespace/platform_linux_test.go rename to pkg/isolation/platform_linux_test.go index 68a31b0ba..c9a14e4eb 100644 --- a/pkg/namespace/platform_linux_test.go +++ b/pkg/isolation/platform_linux_test.go @@ -1,6 +1,6 @@ //go:build linux -package namespace +package isolation import ( "os" diff --git a/pkg/namespace/platform_other.go b/pkg/isolation/platform_other.go similarity index 64% rename from pkg/namespace/platform_other.go rename to pkg/isolation/platform_other.go index a9fdd784d..9c517f847 100644 --- a/pkg/namespace/platform_other.go +++ b/pkg/isolation/platform_other.go @@ -1,6 +1,6 @@ //go:build !linux && !windows -package namespace +package isolation import ( "os/exec" @@ -9,6 +9,8 @@ import ( ) 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 } diff --git a/pkg/namespace/platform_windows.go b/pkg/isolation/platform_windows.go similarity index 85% rename from pkg/namespace/platform_windows.go rename to pkg/isolation/platform_windows.go index 925c0b202..0776557cd 100644 --- a/pkg/namespace/platform_windows.go +++ b/pkg/isolation/platform_windows.go @@ -1,6 +1,6 @@ //go:build windows -package namespace +package isolation import ( "fmt" @@ -16,6 +16,8 @@ import ( 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 @@ -36,12 +38,14 @@ func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, roo cmd.SysProcAttr = &syscall.SysProcAttr{} } rules := BuildWindowsAccessRules(root, isolation.ExposePaths) - logger.InfoCF("namespace", "windows isolation access rules", + logger.InfoCF("isolation", "windows isolation access rules", map[string]any{ "root": root, "command": cmd.Path, "rules": formatWindowsAccessRules(rules), }) + // 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) @@ -58,6 +62,8 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, } 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 { @@ -119,6 +125,8 @@ func reapWindowsProcessResources(pid int, proc windows.Handle, job windows.Handl 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( @@ -156,6 +164,8 @@ func createRestrictedPrimaryToken() (windows.Token, error) { 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 { @@ -178,6 +188,7 @@ func setTokenLowIntegrity(token windows.Token) error { 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 { diff --git a/pkg/namespace/runtime.go b/pkg/isolation/runtime.go similarity index 78% rename from pkg/namespace/runtime.go rename to pkg/isolation/runtime.go index 2be872077..4e4085b0a 100644 --- a/pkg/namespace/runtime.go +++ b/pkg/isolation/runtime.go @@ -1,4 +1,4 @@ -package namespace +package isolation import ( "fmt" @@ -13,17 +13,22 @@ import ( "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 @@ -40,6 +45,8 @@ var ( currentWorkspace = config.DefaultConfig().WorkspacePath() ) +// Configure updates the process-wide isolation state used by subsequent child +// process launches. func Configure(cfg *config.Config) { isolationMu.Lock() defer isolationMu.Unlock() @@ -53,18 +60,23 @@ func Configure(cfg *config.Config) { currentWorkspace = filepath.Clean(cfg.WorkspacePath()) } +// CurrentConfig returns the currently active isolation settings. func CurrentConfig() config.IsolationConfig { isolationMu.RLock() defer isolationMu.RUnlock() return currentIsolation } +// CurrentWorkspace returns the workspace path currently associated with the +// configured runtime state. func CurrentWorkspace() string { isolationMu.RLock() defer isolationMu.RUnlock() return currentWorkspace } +// 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 == "." { @@ -73,6 +85,7 @@ func ResolveInstanceRoot() (string, error) { 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 { @@ -82,6 +95,8 @@ func PrepareInstanceRoot(root string) error { 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, @@ -110,6 +125,8 @@ func InstanceDirs(root string) []string { 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{ @@ -123,6 +140,8 @@ func ResolveUserEnv(root string) UserEnv { } } +// 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) @@ -154,6 +173,8 @@ func ApplyUserEnv(cmd *exec.Cmd, root string) { 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 { @@ -182,6 +203,8 @@ func ValidateExposePaths(items []config.ExposePath) error { 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 @@ -195,6 +218,8 @@ func NormalizeExposePath(item config.ExposePath) config.ExposePath { } } +// 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, @@ -213,6 +238,8 @@ func DefaultExposePaths(root string) []config.ExposePath { return items } +// 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)) @@ -234,6 +261,8 @@ func MergeExposePaths(defaults []config.ExposePath, overrides []config.ExposePat 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)) @@ -243,6 +272,8 @@ func BuildLinuxMountPlan(root string, overrides []config.ExposePath) []MountRule return plan } +// BuildWindowsAccessRules derives the host-path access policy used by the +// Windows restricted-token backend. func BuildWindowsAccessRules(root string, overrides []config.ExposePath) []AccessRule { rules := []AccessRule{{Path: root, Mode: "rw"}} if userProfile := os.Getenv("USERPROFILE"); userProfile != "" { @@ -260,6 +291,8 @@ func BuildWindowsAccessRules(root string, overrides []config.ExposePath) []Acces return rules } +// IsSupported reports whether the current platform has an implemented isolation +// backend. func IsSupported() bool { switch runtime.GOOS { case "linux", "windows": @@ -269,6 +302,8 @@ func IsSupported() bool { } } +// 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 { @@ -301,6 +336,8 @@ func Preflight() error { 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 @@ -325,6 +362,8 @@ func Start(cmd *exec.Cmd) error { 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 @@ -349,6 +388,8 @@ func Run(cmd *exec.Cmd) error { return 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 { diff --git a/pkg/namespace/runtime_test.go b/pkg/isolation/runtime_test.go similarity index 99% rename from pkg/namespace/runtime_test.go rename to pkg/isolation/runtime_test.go index 3b3a3e363..db268a05c 100644 --- a/pkg/namespace/runtime_test.go +++ b/pkg/isolation/runtime_test.go @@ -1,4 +1,4 @@ -package namespace +package isolation import ( "os/exec" diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 2acd85aa3..c54289449 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -16,8 +16,8 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/isolation" "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/namespace" ) // headerTransport is an http.RoundTripper that adds custom headers to requests @@ -366,7 +366,9 @@ func (m *Manager) ConnectServer( env = append(env, fmt.Sprintf("%s=%s", k, v)) } cmd.Env = env - if err := namespace.PrepareCommand(cmd); err != nil { + // Apply the shared isolation preparation before the MCP SDK takes over the + // stdio transport so stdio servers see the same isolated environment. + if err := isolation.PrepareCommand(cmd); err != nil { return fmt.Errorf("prepare stdio MCP isolation: %w", err) } diff --git a/pkg/namespace/README.md b/pkg/namespace/README.md deleted file mode 100644 index 129afa0b0..000000000 --- a/pkg/namespace/README.md +++ /dev/null @@ -1,642 +0,0 @@ -# `pkg/namespace` 设计说明 - -本文档描述 `picoclaw` 的实例级隔离设计。目标是让整个 `picoclaw` 实例运行在独立且可维护的隔离环境中。 - -## 目标 - -- 以整个 `picoclaw` 实例作为隔离单位 -- 统一主进程、agent、tools、CLI provider、MCP、hooks、launcher 的运行边界 -- 在 Linux、macOS、Windows 上提供默认隔离能力 -- 让实例迁移、清理、备份、排障更简单 - -## 总体结论 - -- 隔离根直接映射到现有 `config.GetHome()` 语义 -- 提供一个实例级总开关用于开启或关闭隔离 -- Linux 默认使用基于 `bwrap` 的 namespace 隔离 -- macOS 标记为 `TODO`,当前版本暂不实现 -- Windows 默认使用受限访问令牌、低完整性级别和 `Job Object` 隔离 -- 不向用户暴露平台隔离后端选择配置 - -## 隔离单位 - -隔离单位是单个 `picoclaw` 实例。 - -一个实例只认一个实例根目录,实例内所有配置、状态、缓存、工作目录和子进程运行痕迹都从该目录派生。 - -## 与现有配置的关系 - -当前仓库已经有稳定的根目录语义: - -- `config.GetHome()` -- `PICOCLAW_HOME` -- `PICOCLAW_CONFIG` -- 默认 `UserHomeDir() + "/.picoclaw"` -- `config.json` -- `launcher-config.json` -- `workspace` - -因此本设计直接建立在 `config.GetHome()` 之上,不再引入新的全局根配置。 - -实例根解析规则: - -- 如果设置了 `PICOCLAW_HOME`,使用该值 -- 如果未设置 `PICOCLAW_HOME`,使用 `os.UserHomeDir()` 与 `pkg.DefaultPicoClawHome` 拼接后的目录 -- 如果用户目录也无法解析,则回退到当前目录 `.` - -风险说明: - -- 当隔离开启时,不应直接接受 `config.GetHome()` 回退到 `.` 作为最终实例隔离根 -- 如果实例根最终解析为当前目录 `.`,实现应直接报错,避免把非预期工作目录当作实例隔离根 - -配置文件兼容规则: - -- 实例隔离根仍然遵循 `config.GetHome()` 语义 -- 如果未显式设置 `PICOCLAW_CONFIG`,默认配置文件路径为 `/config.json` -- 如果显式设置了 `PICOCLAW_CONFIG`,则继续遵循该路径,不因隔离开启而强制改回实例根目录 -- `.security.yml` 仍按当前项目规则相对于实际 `config.json` 路径解析 - -## 配置 - -实例级隔离通过一个总开关控制。 - -设计约定: - -- `DefaultConfig` 中默认将 `isolation.enabled` 设为 `false` -- 用户可以通过配置显式设为 `true` 开启隔离 - -配置示例: - -```json -{ - "isolation": { - "enabled": true, - "expose_paths": [] - } -} -``` - -规则: - -- `isolation.enabled = true`:启用实例隔离 -- `isolation.enabled = false`:关闭实例隔离 -- `DefaultConfig` 默认应为关闭状态 -- 开启后按平台自动选择隔离后端 -- 不提供平台隔离后端选择配置 -- `isolation.expose_paths` 用于显式把宿主机目录或文件暴露到隔离环境内 - -`expose_paths` 的建议格式: - -```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" - } - ] - } -} -``` - -说明: - -- `target` 表示隔离环境内的目标绝对路径 -- `target` 不等同于实例默认 `workspace` -- Windows 下 `target` 仅用于保持跨平台配置结构一致,不承诺真实文件系统重映射语义 -- 在 Linux 上,`target` 进入 mount 视图;在 Windows 上,权限控制仍以 `source` 为准 - -字段说明: - -- `source`:宿主机路径,可以是目录或单个文件 -- `target`:映射到隔离环境内的目标路径 -- `mode`:`ro` 或 `rw` - -约束: - -- 默认值为空,表示不额外暴露宿主路径 -- 应优先使用 `ro`,只有确实需要写入时才允许 `rw` -- `expose_paths` 是实例级配置,不放到 agent 级别 -- 路径必须显式列出,不支持通配模式 -- `source` 和 `target` 都必须是绝对路径 -- 如果未显式指定 `target`,则默认等于 `source` -- 同一个 `target` 只能保留一条最终规则,后加载配置覆盖先加载配置 - -覆盖规则: - -- 内部默认暴露项先加载 -- 外部配置文件中的 `isolation.expose_paths` 后加载 -- 当 `target` 相同时,外部配置覆盖内部默认项 -- 当 `target` 不同时,规则并存 -- 默认映射规则全部允许被外部配置覆盖 -- 覆盖既可以替换整条 `source -> target` 规则,也可以只调整同一 `target` 的 `mode` -- 覆盖后的最终规则仍必须满足平台启动所需的最小运行时依赖;否则初始化应直接失败 - -最小运行依赖校验示例: - -- Linux 上如果最终映射结果缺少运行命令所需的 `/usr`、`/bin`、`/lib`、`/lib64` 中的必要路径,应直接失败 -- Linux 上如果把关键系统路径错误地重定向到不存在位置,应直接失败 -- Linux 上如果移除关键文件如 `/etc/resolv.conf` 或把其指向非法目标,应直接失败 -- Windows 上如果最终访问规则未包含实例根的必要访问权限,应直接失败 - -这样可以让程序内置必要映射,同时允许部署者在外部配置中替换或重定向这些映射。 - -默认映射目录: - -- `config.GetHome()` 解析出的实例根目录,默认通常为 `$HOME/.picoclaw` -- Linux 下还会默认映射运行所需的最小系统路径,例如 `/usr`、`/bin`、`/lib`、`/lib64`、`/etc/resolv.conf` -- Windows 下实例根目录默认授予必要访问权限;额外宿主目录只有在 `expose_paths` 中显式声明后才会放开 -- `expose_paths` 只用于补充默认映射之外的宿主路径,或覆盖内部默认映射规则 - -默认 workspace 规则: - -- 默认情况下,workspace 仍按当前 `DefaultConfig()` 规则从实例根派生,通常为 `/workspace` -- 如果用户在现有配置中显式设置了 workspace,则以用户配置为准 -- 隔离设计不应强制把已显式配置的 workspace 重写回 `/workspace` - -## 实例目录布局 - -整个实例目录布局统一为: - -```text -/ - config.json - .security.yml - launcher-config.json - workspace/ - skills/ - logs/ - cache/ - state/ - runtime-user-env/ -``` - -说明: - -- `workspace/` 表示默认工作目录布局;若用户显式配置 workspace,则以实际配置为准 -- `skills/`、`logs/`、`cache/`、`state/` 统一放在实例根下 -- `runtime-user-env/` 是实例子进程使用的私有运行环境目录 -- 平台隔离后端必须围绕这套实例目录布局工作,不能绕过实例根直接落回宿主机用户目录 -- `config.json` 表示默认配置文件位置;若显式设置 `PICOCLAW_CONFIG`,则以实际配置路径为准 - -## 配置示例 - -```json -{ - "isolation": { - "enabled": true, - "expose_paths": [ - { - "source": "/opt/toolchains/go", - "target": "/opt/toolchains/go", - "mode": "ro" - } - ] - }, - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -配合环境变量: - -```bash -export PICOCLAW_HOME=/opt/picoclaw-instance-a -``` - -此时推荐运行语义为: - -- `config.json` 位于 `/opt/picoclaw-instance-a/config.json` -- 默认 workspace 位于 `/opt/picoclaw-instance-a/workspace` -- 所有实例状态都位于 `/opt/picoclaw-instance-a/` 下 - -如果未设置 `PICOCLAW_HOME`,则实例根默认按 `config.GetHome()` 解析,通常为用户目录下的 `.picoclaw`。 - -## 平台策略 - -### Linux - -Linux 默认使用基于 `bwrap` 的 namespace 隔离。 - -默认要求: - -- 使用 `mount namespace` -- 使用 `ipc namespace` -- 使用 `bwrap` 构造独立文件系统视图 -- 只暴露实例运行所需的最小文件系统视图 -- 把 `config.GetHome()` 解析出的实例根直接映射到隔离环境中 -- 把 `isolation.expose_paths` 中声明的 `source -> target` 路径按只读或读写方式显式映射到隔离环境中 - -实现说明: - -- 当前 Linux 后端依赖系统已安装 `bwrap` (`bubblewrap`) -- 如果 `bwrap` 不可用,Linux 隔离初始化应直接报错,不允许静默回退到未隔离启动 -- 可参考的安装命令:`apt install bubblewrap`、`dnf install bubblewrap`、`yum install bubblewrap`、`pacman -S bubblewrap`、`apk add bubblewrap` -- 如果临时无法安装 `bwrap`,也可以在配置中将 `isolation.enabled` 显式设为 `false` 关闭隔离 -- 关闭隔离后,子进程将不再使用 Linux namespace 文件系统隔离,访问和修改宿主机文件的风险会明显上升 - -### macOS - -macOS 当前标记为 `TODO`,暂不实现。 - -对外说明: - -- 当前版本不承诺 macOS 隔离能力 -- 当前版本不默认启用 macOS sandbox -- 当前版本不提供 macOS 平台上的强隔离保证 - -后续实现目标: - -- `~/.ssh` -- `~/.gitconfig` -- `~/Documents` -- `~/Desktop` -- `~/Downloads` - -在后续版本实现 macOS sandbox 后,再定义具体启动链路和失败策略。 - -### Windows - -Windows 默认使用受限访问令牌、低完整性级别和 `Job Object` 隔离。 - -默认要求: - -- 使用受限访问令牌启动实例创建的子进程 -- 将子进程令牌降到低完整性级别 -- 使用 `Job Object` 约束进程树生命周期 -- 对实例根目录授予必要读写权限 -- 对实例根目录之外的宿主目录默认不授予额外写权限 -- 对敏感宿主目录通过低权限令牌和低完整性级别进一步压缩写入能力 -- 对 `isolation.expose_paths` 中声明的 `source -> target` 路径按显式白名单放开访问权限 - -说明: - -- Windows 不对外实现真正的 `source -> target` 文件系统重映射 -- Windows 上的核心能力是“按 `source` 控制访问权限”,而不是像 Linux mount namespace 那样把任意宿主路径稳定地挂载到另一个隔离目标路径 -- 因此在 Windows 上,`target` 主要用于保持跨平台配置结构一致,以及供隔离环境内路径解析使用;真正的权限控制仍然基于 `source` -- 如果未来 Windows 侧具备稳定、低复杂度的目标路径重映射实现,再单独扩展该能力 - -Windows 上如果受限令牌、低完整性级别或 `Job Object` 初始化失败,应直接报错,不允许回退到普通用户权限启动。 - -## 生效范围 - -当 `isolation.enabled=true` 时,实例隔离覆盖整个实例的所有运行路径: - -- 主进程默认目录解析 -- agent workspace 解析 -- `exec` 工具 -- CLI provider,例如 `claude-cli`、`codex-cli` -- 进程型 hooks -- 启动型 MCP server -- launcher 相关运行目录 -- skills、state、sessions、logs、cache 的默认目录 -- 其他后续新增的 `exec.Command` / `exec.CommandContext` 路径 - -其中: - -- Linux 上这些路径都必须进入 `namespace` 启动链路 -- macOS 当前暂无隔离启动链路,后续补齐 -- Windows 上这些路径都必须进入受限令牌 + 低完整性级别 + `Job Object` 启动链路 - -初始化顺序: - -1. 先按当前项目规则解析 `config.GetHome()` 和 `PICOCLAW_CONFIG` -2. 再读取配置文件并得到最终配置 -3. 根据最终配置判断 `isolation.enabled` -4. 只有在启用隔离后,才初始化平台隔离后端 -5. 后续由 `picoclaw` 启动的子进程统一进入对应平台的隔离启动链路 - -这样可以避免“先隔离才能读配置”与“先读配置才能决定是否隔离”的循环依赖。 - -## 与 `restrict_to_workspace` 的关系 - -- 实例隔离负责“整个 picoclaw 把哪里当作自己的运行世界” -- `restrict_to_workspace` 负责“agent 在这个世界里默认能访问哪些路径” -- `isolation.expose_paths` 负责“哪些宿主路径以什么目标路径被显式带入隔离环境” - -两者互补,不互相替代。 - -## 失败策略 - -当 `isolation.enabled=true` 时,以下情况都应直接报错: - -- 实例根目录准备失败 -- 默认目录收敛失败 -- 平台隔离后端初始化失败 -- 子进程隔离启动失败 - -不允许静默回退到宿主机默认用户目录,也不允许回退到未隔离启动。 - -当 `isolation.enabled=false` 时: - -- 不启用 Linux `namespace` -- 不启用 Windows 受限访问令牌、低完整性级别和 `Job Object` 隔离 -- 不处理 `isolation.expose_paths` -- 进程按当前普通运行方式启动 - -## 安全边界 - -当 `isolation.enabled=true` 时,本设计提供的是 `picoclaw` 的隔离运行模型: - -- Linux 使用基于 `bwrap` 的 namespace 隔离 -- macOS 当前为 `TODO` -- Windows 使用受限访问令牌、低完整性级别和 `Job Object` 隔离 - -它能解决的问题: - -- 避免 `picoclaw` 的配置、日志、状态、skills、缓存散落在宿主机默认用户目录 -- 避免多个 `picoclaw` 实例共享同一套运行状态 -- 为实例内子进程提供统一的默认隔离启动链路 -- 让实例迁移、备份、删除、排障更简单 - -关于“误删重要资料、读取重要资料”的结论: - -- Linux 和 Windows 上,隔离的目标是显著降低误删宿主重要资料、误读宿主敏感文件的风险 -- 当前设计不承诺绝对杜绝所有敏感文件访问或宿主文件破坏 -- macOS 当前未实现隔离,不应依赖其提供这类保护 -- 一旦配置了 `isolation.expose_paths`,这些路径就会成为隔离环境内可见路径,因此需要由用户自行控制暴露范围 - -它不承诺: - -- 三个平台提供完全相同的底层隔离原语 -- 容器或 VM 级完全等价边界 -- 绝对杜绝所有私密文件访问 -- 绝对杜绝所有宿主文件破坏 - -以下情况仍然可能带来风险: - -- Linux 上 namespace/root 视图暴露范围过大,把宿主敏感路径一并映射进隔离环境 -- 用户在 `isolation.expose_paths` 中加入了不必要的敏感目录或使用了过宽的 `rw` 暴露 -- Linux 上为了兼容工具链而额外放宽挂载、网络或系统路径访问范围 -- Windows 上受限令牌或低完整性级别限制不足,仍保留了过宽的宿主访问能力 -- Windows 上为了兼容某些 CLI、编译器或运行时而额外放宽文件访问权限 -- 子进程本身需要访问的“必要系统路径”定义过宽,间接扩大了可读取或可写入范围 -- `restrict_to_workspace=false` 或额外白名单路径配置过宽,使 agent 能接触更多宿主路径 -- 被执行的命令、脚本、构建工具或第三方 CLI 本身具有高风险行为,即使在隔离下仍可能破坏已暴露路径内的数据 -- 平台隔离后端实现存在漏洞、遗漏调用路径,或某些子进程没有走统一隔离启动链路 -- macOS 当前未实现隔离,运行时仍可能直接接触宿主机用户目录 - -真正高风险的不可信代码执行场景,仍应放到容器或 VM 中处理。 - -## 实现伪代码 - -下面的伪代码只展示整体实现思路,便于评审,不代表最终代码结构必须完全一致。 - -### 1. 解析实例根和隔离配置 - -```go -type IsolationConfig struct { - Enabled bool `json:"enabled,omitempty"` - ExposePaths []ExposePath `json:"expose_paths,omitempty"` -} - -type ExposePath struct { - Source string `json:"source"` - Target string `json:"target,omitempty"` - Mode string `json:"mode"` // ro | rw -} - -func resolveInstanceRoot() string { - // 直接复用项目现有的实例根解析逻辑。 - return config.GetHome() -} -``` - -### 2. 启动时准备实例目录 - -```go -func prepareInstanceRoot(root string) error { - dirs := []string{ - root, - filepath.Join(root, "workspace"), - filepath.Join(root, "skills"), - filepath.Join(root, "logs"), - filepath.Join(root, "cache"), - filepath.Join(root, "state"), - filepath.Join(root, "runtime-user-env"), - } - - for _, dir := range dirs { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("prepare instance dir %s: %w", dir, err) - } - } - return nil -} -``` - -### 3. 校验 `expose_paths` - -```go -func validateExposePaths(items []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(expandHome(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 -} -``` - -### 4. Linux: 构建 `bwrap` 文件系统视图 - -```go -type LinuxMount struct { - Source string - Target string - Mode string // ro | rw -} - -func buildLinuxMountPlan(root string, expose []ExposePath) []LinuxMount { - plan := []LinuxMount{ - // 直接把 config.GetHome() 解析出的实例根映射为隔离根。 - {Source: root, Target: root, Mode: "rw"}, - {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"}, - } - - for _, item := range expose { - source := filepath.Clean(expandHome(item.Source)) - target := item.Target - if target == "" { - target = source - } - target = filepath.Clean(target) - plan = append(plan, LinuxMount{Source: source, Target: target, Mode: item.Mode}) - } - return plan -} - -func startLinuxIsolated(cmd *exec.Cmd, root string, expose []ExposePath) error { - plan := buildLinuxMountPlan(root, expose) - - // 伪代码: - // 1. 生成 bwrap 参数 - // 2. 打开 ipc namespace - // 3. 把 config.GetHome() 解析出的实例根 bind mount 进隔离视图 - // 4. 再按 expose_paths 和必要系统路径补充 bind mount - // 5. ro 条目用 ro-bind - // 6. 切换 cwd 到 filepath.Join(root, "workspace") - // 7. 通过 bwrap exec 启动目标进程 - - _ = plan - return runInLinuxNamespaces(cmd) -} -``` - -### 5. Windows: 用受限令牌、低完整性级别和 `Job Object` 启动 - -```go -type WindowsAccessRule struct { - Path string - Mode string // ro | rw | deny -} - -func buildWindowsAccessRules(root string, expose []ExposePath) []WindowsAccessRule { - rules := []WindowsAccessRule{ - {Path: root, Mode: "rw"}, - {Path: filepath.Join(os.Getenv("USERPROFILE"), ".ssh"), Mode: "deny"}, - {Path: filepath.Join(os.Getenv("USERPROFILE"), "Documents"), Mode: "deny"}, - {Path: filepath.Join(os.Getenv("USERPROFILE"), "Desktop"), Mode: "deny"}, - } - - for _, item := range expose { - source := filepath.Clean(expandHome(item.Source)) - _ = item.Target // Windows 权限以 source 为准,target 用于隔离环境内路径解析。 - rules = append(rules, WindowsAccessRule{Path: source, Mode: item.Mode}) - } - return rules -} - -func startWindowsIsolated(cmd *exec.Cmd, root string, expose []ExposePath) error { - rules := buildWindowsAccessRules(root, expose) - - // 伪代码: - // 1. 创建受限访问令牌 - // 2. 将令牌降到低完整性级别 - // 3. 创建 Job Object - // 4. 以受限令牌启动子进程并加入 Job Object - - _ = rules - return runWithRestrictedTokenAndJobObject(cmd) -} -``` - -### 6. macOS: 预留统一入口 - -```go -func startDarwinIsolated(cmd *exec.Cmd, root string, expose []ExposePath) error { - return fmt.Errorf("macOS isolation is TODO") -} -``` - -### 7. 统一子进程启动入口 - -```go -func Configure(cfg IsolationConfig) - -func startIsolatedProcess(cmd *exec.Cmd, root string) error { - cfg := CurrentConfig() - if err := prepareInstanceRoot(root); err != nil { - return err - } - if err := validateExposePaths(cfg.ExposePaths); err != nil { - return err - } - - switch runtime.GOOS { - case "linux": - return startLinuxIsolated(cmd, root, cfg.ExposePaths) - case "windows": - return startWindowsIsolated(cmd, root, cfg.ExposePaths) - case "darwin": - return startDarwinIsolated(cmd, root, cfg.ExposePaths) - default: - return fmt.Errorf("isolation unsupported on %s", runtime.GOOS) - } -} -``` - -### 8. 统一进程启动封装 - -```go -func newExecTool(...) *ExecTool { - // exec 工具内部所有命令最终走 startIsolatedProcess -} - -func newCLIProvider(...) Provider { - // claude-cli / codex-cli 等在启动命令时走 startIsolatedProcess -} - -func startMCPServer(...) error { - // MCP server 启动时走 startIsolatedProcess -} - -func runProcessHook(...) error { - // 进程型 hooks 走 startIsolatedProcess -} -``` - -为什么需要这一步: - -- 当前代码里存在多处独立的 `exec.Command` / `exec.CommandContext` 调用,进程创建没有统一收口 -- `exec` 工具、CLI provider、MCP stdio server、process hook 都会各自启动子进程 -- 如果不统一收口,后续很容易出现某些路径没有进入隔离启动链路的问题 -- Linux 上即使主进程已经先进入 `namespace`,统一封装仍然有价值,因为它可以确保所有 spawn 路径都使用同一套约束和默认行为 -- Windows 上这一步是必需的,因为受限访问令牌、低完整性级别和 `Job Object` 需要在创建子进程时施加,不能只依赖主进程“已经被隔离” -- 统一封装的目标不是让每个模块各自实现隔离逻辑,而是让所有子进程创建都复用同一个入口 - -### 9. 关键实现原则 - -- 隔离总开关只在实例级生效,不下沉到 agent 级别 -- `expose_paths` 只做显式白名单,不做通配符和隐式继承 -- Linux 以最小挂载视图为核心,禁止“先暴露整个宿主根目录再黑名单过滤” -- Windows 以“实例根允许、敏感目录拒绝、显式路径白名单放开”为核心 -- 任何需要启动子进程的路径都必须复用同一个隔离启动入口,不能各自实现 - -## 最终方案 - -- 用 `config.GetHome()` 解析出的实例根作为整个 `picoclaw` 的隔离根 -- 通过 `isolation.enabled` 作为实例级总开关 -- 通过 `isolation.expose_paths` 显式声明需要带入隔离环境的宿主路径 -- Linux 默认使用基于 `bwrap` 的 namespace 隔离 -- macOS 标记为 `TODO`,当前版本暂不实现 -- Windows 默认使用受限访问令牌、低完整性级别和 `Job Object` 隔离 -- 所有默认目录都优先从实例根统一派生;若存在现有显式 override,则继续遵循该 override -- 不新增平台隔离后端选择配置项 - -这套方案适合作为对外公布版本:边界清晰、配置最少、平台行为明确,也更利于整个项目长期维护。 diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 4750cfb17..c3d98c555 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -8,7 +8,7 @@ import ( "os/exec" "strings" - "github.com/sipeed/picoclaw/pkg/namespace" + "github.com/sipeed/picoclaw/pkg/isolation" ) // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. @@ -51,7 +51,9 @@ func (p *ClaudeCliProvider) Chat( cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := namespace.Run(cmd); 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 1096588c5..a9c8b692a 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -9,7 +9,7 @@ import ( "os/exec" "strings" - "github.com/sipeed/picoclaw/pkg/namespace" + "github.com/sipeed/picoclaw/pkg/isolation" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. @@ -58,7 +58,9 @@ func (p *CodexCliProvider) Chat( cmd.Stdout = &stdout cmd.Stderr = &stderr - err := namespace.Run(cmd) + // 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 2d7029bdc..a570ac9ec 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -20,7 +20,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" - "github.com/sipeed/picoclaw/pkg/namespace" + "github.com/sipeed/picoclaw/pkg/isolation" ) var ( @@ -379,7 +379,9 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := namespace.Start(cmd); 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)) } @@ -522,7 +524,9 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn session.stdinWriter = stdinWriter } - if err := namespace.Start(cmd); 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() }