mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Feat/support isolation (#2423)
* * completed * * optimzie * * fix format * * fix pr check * try to fix ci * * Indicates that Windows does not support expos_paths, adding more mount paths for the Linux platform. * fix isolation startup lifecycle and MCP transport wrapping * fix isolation startup cleanup and optional Linux mounts * fix isolation path handling for relative hooks Preserve relative command and working-directory semantics when Linux isolation wraps subprocesses, and restore absolute argv path exposure to avoid startup regressions. Add hook coverage and docs updates so isolation-enabled process hooks keep working as configured. * * fix ci
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+29
-13
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
这样能最快建立对配置模型、运行流程和平台边界的整体理解。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
+1
-2
@@ -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)",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
-7
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user