Files
43837afca3 fix(security): consolidate & enhance batched security fixes (#1155, #967, #972, #974, #989, #973) (#1185)
* fix(sandbox): avoid shell in FsBridge writes

Replace sh -c with interpolated path by shell-free 'tee -- <path>' argv form,
piping content via stdin. Prevents command injection through filenames
containing shell metacharacters inside the sandbox container.

Co-authored-by: evgyur <evgyur@gmail.com>

* fix(security): fail-closed on pairing DB errors across channels

On IsPaired lookup error, deny instead of granting access. Covers the shared
CheckDMPolicy/CheckGroupPolicy helpers (Slack/Discord/Feishu/WhatsApp/Zalo) and
the four inline Telegram pairing checks.

Co-authored-by: Srini <srinis.k@gmail.com>

* fix(security): harden provider URL validation against SSRF

Enforce scheme check for all provider types; restrict local types (ollama,
claude_cli, acp) to an explicit localhost allowlist instead of skipping checks;
resolve remote hostnames and reject any IP in a private/reserved range via the
shared security.IsBlocked CIDR list (covers loopback, link-local, metadata,
multicast, and unspecified 0.0.0.0/::). Closes the wildcard-DNS bypass and the
local-type escape hatch. Operator opt-in via GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS.

Exports security.IsBlocked as the single source of truth for blocked ranges.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* feat(pipeline): add fail-closed tool call authorization gate

Gate tool execution against the server-side AllowedTools allowlist built from the
RBAC/tenant-aware filtered tool set. Resolve the tool-call prefix before the
allowlist lookup so prefixed agents are not wrongly blocked, re-check deny on lazy
MCP activation, and expand IsDenied to cover aliased tool names.

Co-authored-by: Huy Doan <tui@pm.me>

* fix(security): expand file-serve deny-list defense-in-depth

Add absolute-path deny prefixes (/home, /Users, /srv, /var/lib, /var/www, /opt)
and an explicit fail-closed log when no file-serving boundary is configured.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* fix(providers): allow claude cli executable paths

Refs: #1185

---------

Co-authored-by: evgyur <evgyur@gmail.com>
Co-authored-by: Srini <srinis.k@gmail.com>
Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>
Co-authored-by: Huy Doan <tui@pm.me>
2026-06-05 00:48:38 +07:00

274 lines
7.7 KiB
Go

// Package sandbox — fsbridge.go provides sandboxed file operations via Docker exec.
// Matching TS src/agents/sandbox/fs-bridge.ts.
//
// When sandbox is enabled, file tools (read_file, write_file, list_files)
// route through FsBridge instead of direct host filesystem access.
// All operations execute inside the Docker container via "docker exec".
package sandbox
import (
"bytes"
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// FsBridge provides sandboxed file operations via Docker exec.
// Matching TS SandboxFsBridge in fs-bridge.ts.
type FsBridge struct {
containerID string
workdir string // container-side working directory (e.g. "/workspace")
}
// NewFsBridge creates a bridge to a running sandbox container.
func NewFsBridge(containerID, workdir string) *FsBridge {
if workdir == "" {
workdir = "/workspace"
}
return &FsBridge{
containerID: containerID,
workdir: workdir,
}
}
// ReadFile reads file contents from inside the container.
// Matching TS FsBridge.readFile().
func (b *FsBridge) ReadFile(ctx context.Context, path string) (string, error) {
resolved := b.resolvePath(path)
realPath, err := b.resolveExistingPath(ctx, resolved)
if err != nil {
return "", err
}
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "cat", "--", realPath)
if err != nil {
return "", fmt.Errorf("fsbridge read: %w", err)
}
if exitCode != 0 {
return "", fmt.Errorf("read failed: %s", strings.TrimSpace(stderr))
}
return stdout, nil
}
// WriteFile writes content to a file inside the container, creating directories as needed.
// When append is true, content is appended; otherwise the file is overwritten.
// Matching TS FsBridge.writeFile().
func (b *FsBridge) WriteFile(ctx context.Context, path, content string, appendMode bool) error {
resolved := b.resolvePath(path)
if err := b.validateExistingTargetIfPresent(ctx, resolved); err != nil {
return err
}
dir := resolved[:strings.LastIndex(resolved, "/")]
if dir != "" && dir != "/" {
if err := b.validateParentBeforeCreate(ctx, dir); err != nil {
return err
}
_, stderr, exitCode, err := b.dockerExec(ctx, nil, "mkdir", "-p", "--", dir)
if err != nil {
return fmt.Errorf("fsbridge mkdir: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("mkdir failed: %s", strings.TrimSpace(stderr))
}
if err := b.validateParentBeforeCreate(ctx, dir); err != nil {
return err
}
}
// Write content via stdin without invoking a shell. Passing the resolved path
// as a discrete argv entry prevents shell metacharacters in filenames from
// being interpreted as commands inside the sandbox container.
// "--" terminates option parsing so paths starting with "-" are safe.
teeArgs := fsBridgeWriteTeeArgs(resolved, appendMode)
_, stderr, exitCode, err := b.dockerExec(ctx, []byte(content), teeArgs...)
if err != nil {
return fmt.Errorf("fsbridge write: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("write failed: %s", strings.TrimSpace(stderr))
}
return nil
}
func fsBridgeWriteTeeArgs(resolved string, appendMode bool) []string {
args := []string{"tee"}
if appendMode {
args = append(args, "-a")
}
args = append(args, "--", resolved)
return args
}
// ListDir lists files and directories inside the container.
// Matching TS FsBridge.readdir().
func (b *FsBridge) ListDir(ctx context.Context, path string) (string, error) {
resolved := b.resolvePath(path)
realPath, err := b.resolveExistingPath(ctx, resolved)
if err != nil {
return "", err
}
// Use ls -la for detailed listing
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "ls", "-la", "--", realPath)
if err != nil {
return "", fmt.Errorf("fsbridge list: %w", err)
}
if exitCode != 0 {
return "", fmt.Errorf("list failed: %s", strings.TrimSpace(stderr))
}
return stdout, nil
}
// Stat checks if a path exists and returns basic info.
func (b *FsBridge) Stat(ctx context.Context, path string) (string, error) {
resolved := b.resolvePath(path)
realPath, err := b.resolveExistingPath(ctx, resolved)
if err != nil {
return "", err
}
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "stat", "--", realPath)
if err != nil {
return "", fmt.Errorf("fsbridge stat: %w", err)
}
if exitCode != 0 {
return "", fmt.Errorf("stat failed: %s", strings.TrimSpace(stderr))
}
return stdout, nil
}
// resolvePath resolves a path relative to the container workdir.
// Validates that absolute paths stay within the workdir (defense in depth).
func (b *FsBridge) resolvePath(path string) string {
workdir := filepath.Clean(b.workdir)
if path == "" || path == "." {
return workdir
}
var cleaned string
if strings.HasPrefix(path, "/") {
cleaned = filepath.Clean(path)
} else {
cleaned = filepath.Clean(filepath.Join(workdir, path))
}
if cleaned == workdir || strings.HasPrefix(cleaned, workdir+"/") {
return cleaned
}
return workdir
}
func fsBridgePathWithin(root, target string) bool {
root = filepath.Clean(root)
target = filepath.Clean(target)
if target == root {
return true
}
return strings.HasPrefix(target, root+"/")
}
func (b *FsBridge) containerRealPath(ctx context.Context, path string) (string, error) {
stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "realpath", "-e", "--", path)
if err != nil {
return "", fmt.Errorf("fsbridge realpath: %w", err)
}
if exitCode != 0 {
return "", fmt.Errorf("realpath failed: %s", strings.TrimSpace(stderr))
}
return strings.TrimSpace(stdout), nil
}
func (b *FsBridge) containerRealWorkdir(ctx context.Context) (string, error) {
return b.containerRealPath(ctx, filepath.Clean(b.workdir))
}
func (b *FsBridge) resolveExistingPath(ctx context.Context, resolved string) (string, error) {
realWorkdir, err := b.containerRealWorkdir(ctx)
if err != nil {
return "", err
}
realPath, err := b.containerRealPath(ctx, resolved)
if err != nil {
return "", err
}
if !fsBridgePathWithin(realWorkdir, realPath) {
return "", fmt.Errorf("path escapes sandbox workdir")
}
return realPath, nil
}
func (b *FsBridge) validateExistingTargetIfPresent(ctx context.Context, resolved string) error {
realWorkdir, err := b.containerRealWorkdir(ctx)
if err != nil {
return err
}
realPath, err := b.containerRealPath(ctx, resolved)
if err != nil {
return nil
}
if !fsBridgePathWithin(realWorkdir, realPath) {
return fmt.Errorf("path escapes sandbox workdir")
}
return nil
}
func (b *FsBridge) validateParentBeforeCreate(ctx context.Context, dir string) error {
realWorkdir, err := b.containerRealWorkdir(ctx)
if err != nil {
return err
}
current := filepath.Clean(dir)
for {
realParent, err := b.containerRealPath(ctx, current)
if err == nil {
if !fsBridgePathWithin(realWorkdir, realParent) {
return fmt.Errorf("path parent escapes sandbox workdir")
}
return nil
}
next := filepath.Dir(current)
if next == current {
return fmt.Errorf("path parent does not exist inside sandbox workdir")
}
current = next
}
}
// dockerExec runs a command inside the container and returns stdout, stderr, exit code.
func (b *FsBridge) dockerExec(ctx context.Context, stdin []byte, args ...string) (string, string, int, error) {
dockerArgs := []string{"exec"}
if stdin != nil {
dockerArgs = append(dockerArgs, "-i")
}
dockerArgs = append(dockerArgs, b.containerID)
dockerArgs = append(dockerArgs, args...)
cmd := exec.CommandContext(ctx, "docker", dockerArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
err = nil // non-zero exit is not an execution error
} else {
return "", "", -1, err
}
}
return stdout.String(), stderr.String(), exitCode, nil
}