From 46269894071776817193e2d0517caa59255fc97a Mon Sep 17 00:00:00 2001 From: lxowalle Date: Wed, 8 Apr 2026 16:24:22 +0800 Subject: [PATCH] * Indicates that Windows does not support expos_paths, adding more mount paths for the Linux platform. --- pkg/config/config.go | 2 +- pkg/isolation/README.md | 8 +- pkg/isolation/README_CN.md | 8 +- pkg/isolation/platform_windows.go | 3 +- pkg/isolation/runtime.go | 49 ++++-- pkg/isolation/runtime_test.go | 32 +++- pkg/mcp/isolated_command_transport.go | 224 ++++++++++++++++++++++++++ pkg/mcp/manager.go | 2 +- 8 files changed, 304 insertions(+), 24 deletions(-) create mode 100644 pkg/mcp/isolated_command_transport.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 96e2f58cb..fd4466b8c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -54,7 +54,7 @@ type IsolationConfig struct { } // ExposePath describes a host path that should remain visible inside the isolated -// child-process environment. +// child-process environment. This is currently implemented on Linux only. type ExposePath struct { Source string `json:"source"` Target string `json:"target,omitempty"` diff --git a/pkg/isolation/README.md b/pkg/isolation/README.md index ee0461e33..623bb492b 100644 --- a/pkg/isolation/README.md +++ b/pkg/isolation/README.md @@ -46,7 +46,7 @@ Isolation lives under: 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`. +- `expose_paths`: explicitly exposes host paths inside the isolated environment. It only matters when `enabled=true`. This is currently supported on Linux only. Example: @@ -82,7 +82,7 @@ Rules for `expose_paths`: 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`. +- Windows does not currently support `expose_paths`. ## Instance Root And Directories @@ -173,6 +173,10 @@ Disabling isolation increases the risk that child processes can access or modify ### 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 diff --git a/pkg/isolation/README_CN.md b/pkg/isolation/README_CN.md index c61a3b5fa..4592a7fe8 100644 --- a/pkg/isolation/README_CN.md +++ b/pkg/isolation/README_CN.md @@ -46,7 +46,7 @@ 字段说明: - `enabled`:是否启用子进程隔离。默认值:`false`。 -- `expose_paths`:显式把宿主路径带入隔离环境。仅在 `enabled=true` 时生效。 +- `expose_paths`:显式把宿主路径带入隔离环境。仅在 `enabled=true` 时生效。目前只在 Linux 上支持。 示例: @@ -82,7 +82,7 @@ 平台说明: - Linux 会真实使用 `source -> target` 挂载视图。 -- Windows 当前保留 `target` 结构,但权限控制仍以 `source` 为准。 +- Windows 当前不支持 `expose_paths`。 ## 实例根与目录 @@ -173,6 +173,10 @@ Linux 后端当前依赖 `bwrap`(`bubblewrap`)。 ### Windows +Windows 隔离当前提供的是进程级限制,例如 restricted token、low integrity、job object,以及用户环境目录重定向。 + +`expose_paths` 目前不支持 Windows。如果配置了该字段,启动应直接失败,而不是假装这些路径已经被暴露进隔离环境。 + Windows 后端当前使用: - 受限 primary token diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go index 951e74302..7cd86cd4e 100644 --- a/pkg/isolation/platform_windows.go +++ b/pkg/isolation/platform_windows.go @@ -39,11 +39,12 @@ func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, roo cmd.SysProcAttr = &syscall.SysProcAttr{} } rules := BuildWindowsAccessRules(root, isolation.ExposePaths) - logger.InfoCF("isolation", "windows isolation access rules", + 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. diff --git a/pkg/isolation/runtime.go b/pkg/isolation/runtime.go index 1124d9871..f2c6217ae 100644 --- a/pkg/isolation/runtime.go +++ b/pkg/isolation/runtime.go @@ -227,17 +227,33 @@ func DefaultExposePaths(root string) []config.ExposePath { Mode: "rw", }} if runtime.GOOS == "linux" { - items = append(items, - config.ExposePath{Source: "/usr", Target: "/usr", Mode: "ro"}, - config.ExposePath{Source: "/bin", Target: "/bin", Mode: "ro"}, - config.ExposePath{Source: "/lib", Target: "/lib", Mode: "ro"}, - config.ExposePath{Source: "/lib64", Target: "/lib64", Mode: "ro"}, - config.ExposePath{Source: "/etc/resolv.conf", Target: "/etc/resolv.conf", Mode: "ro"}, - ) + items = append(items, defaultLinuxSystemExposePaths()...) } return items } +func defaultLinuxSystemExposePaths() []config.ExposePath { + return []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"}, + } +} + // 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 { @@ -276,21 +292,19 @@ func BuildLinuxMountPlan(root string, overrides []config.ExposePath) []MountRule // Windows restricted-token backend. func BuildWindowsAccessRules(root string, overrides []config.ExposePath) []AccessRule { rules := []AccessRule{{Path: root, Mode: "rw"}} - if userProfile := os.Getenv("USERPROFILE"); userProfile != "" { - rules = append(rules, - AccessRule{Path: filepath.Join(userProfile, ".ssh"), Mode: "deny"}, - AccessRule{Path: filepath.Join(userProfile, ".gitconfig"), Mode: "deny"}, - AccessRule{Path: filepath.Join(userProfile, "Documents"), Mode: "deny"}, - AccessRule{Path: filepath.Join(userProfile, "Desktop"), Mode: "deny"}, - AccessRule{Path: filepath.Join(userProfile, "Downloads"), Mode: "deny"}, - ) - } for _, item := range MergeExposePaths(nil, overrides) { 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 { @@ -334,6 +348,9 @@ func Preflight() error { } } 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") diff --git a/pkg/isolation/runtime_test.go b/pkg/isolation/runtime_test.go index 6f0261041..5be9324db 100644 --- a/pkg/isolation/runtime_test.go +++ b/pkg/isolation/runtime_test.go @@ -114,7 +114,6 @@ func TestBuildLinuxMountPlan(t *testing.T) { } func TestBuildWindowsAccessRules(t *testing.T) { - t.Setenv("USERPROFILE", `C:\Users\tester`) rules := BuildWindowsAccessRules( `C:\picoclaw`, []config.ExposePath{{Source: `D:\data`, Target: `C:\mapped`, Mode: "ro"}}, @@ -140,6 +139,37 @@ func TestBuildWindowsAccessRules(t *testing.T) { } } +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{ + "/etc/hosts": false, + "/etc/nsswitch.conf": false, + "/etc/ssl": false, + "/usr/share/zoneinfo": false, + "/etc/localtime": 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 TestPrepareCommand_AppliesUserEnv(t *testing.T) { t.Setenv(config.EnvHome, filepath.Join(t.TempDir(), "home")) if runtime.GOOS == "linux" { diff --git a/pkg/mcp/isolated_command_transport.go b/pkg/mcp/isolated_command_transport.go new file mode 100644 index 000000000..ee6f6ebbf --- /dev/null +++ b/pkg/mcp/isolated_command_transport.go @@ -0,0 +1,224 @@ +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) +var _ sdkmcp.Connection = (*isolatedIOConn)(nil) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index c54289449..f7a0b6b68 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -372,7 +372,7 @@ func (m *Manager) ConnectServer( return fmt.Errorf("prepare stdio MCP isolation: %w", err) } - transport = &mcp.CommandTransport{Command: cmd} + transport = &isolatedCommandTransport{Command: cmd} default: return fmt.Errorf( "unsupported transport type: %s (supported: stdio, sse, http)",