package host import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // SandboxConfig configures the sandbox execution environment. type SandboxConfig struct { AllowedCommands []string AllowedDirs []string MaxOutputBytes int DefaultTimeout time.Duration MaxTimeout time.Duration } // DefaultSandboxConfig returns a safe default configuration. func DefaultSandboxConfig() SandboxConfig { return SandboxConfig{ AllowedCommands: []string{ "echo", "cat", "ls", "dir", "pwd", "date", "time", "wc", "head", "tail", "sort", "uniq", "grep", "find", "python", "python3", "node", "go", "rustc", "cargo", "git", "curl", "wget", "ping", "nslookup", "tracert", "dotnet", "java", "javac", "gcc", "g++", "make", "cmake", "npm", "npx", "yarn", "pnpm", "pip", "pip3", "docker", "kubectl", "helm", "ffmpeg", "ffprobe", "imagemagick", "convert", "systeminfo", "tasklist", "taskkill", "netstat", }, MaxOutputBytes: 512 * 1024, DefaultTimeout: 30 * time.Second, MaxTimeout: 300 * time.Second, } } // Sandbox provides a safe execution environment for host commands. type Sandbox struct { cfg SandboxConfig } // NewSandbox creates a new sandbox. func NewSandbox(cfg SandboxConfig) *Sandbox { return &Sandbox{cfg: cfg} } // DirEntry represents a filesystem directory entry. type DirEntry struct { Name string `json:"name"` IsDir bool `json:"is_dir"` Size int64 `json:"size"` ModTime string `json:"mod_time,omitempty"` } // ExecResult holds the result of a sandboxed command execution. type ExecResult struct { Stdout string `json:"stdout"` Stderr string `json:"stderr"` ExitCode int `json:"exit_code"` Duration string `json:"duration"` TimedOut bool `json:"timed_out"` } // Exec runs a command inside the sandbox. The command string is parsed into // the executable name and arguments. Returns the combined output. func (s *Sandbox) Exec(ctx context.Context, command string, workDir string, timeout time.Duration) (*ExecResult, error) { if command == "" { return nil, fmt.Errorf("empty command") } parts := strings.Fields(command) if len(parts) == 0 { return nil, fmt.Errorf("empty command") } cmdName := parts[0] var args []string if len(parts) > 1 { args = parts[1:] } if !s.isCommandAllowed(cmdName) { return nil, fmt.Errorf("command not allowed: %s", cmdName) } if workDir == "" { workDir = s.defaultWorkDir() } if err := s.validateWorkDir(workDir); err != nil { return nil, err } if timeout <= 0 { timeout = s.cfg.DefaultTimeout } if timeout > s.cfg.MaxTimeout { timeout = s.cfg.MaxTimeout } execCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(execCtx, cmdName, args...) cmd.Dir = workDir cmd.Env = s.filteredEnv() var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr start := time.Now() err := cmd.Run() elapsed := time.Since(start) result := &ExecResult{ Duration: elapsed.Round(time.Millisecond).String(), } if stdout.Len() > s.cfg.MaxOutputBytes { result.Stdout = stdout.String()[:s.cfg.MaxOutputBytes] + "\n... [output truncated]" } else { result.Stdout = stdout.String() } if stderr.Len() > s.cfg.MaxOutputBytes { result.Stderr = stderr.String()[:s.cfg.MaxOutputBytes] + "\n... [output truncated]" } else { result.Stderr = stderr.String() } if execCtx.Err() == context.DeadlineExceeded { result.TimedOut = true result.ExitCode = -1 return result, fmt.Errorf("command timed out after %s", timeout) } if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { result.ExitCode = exitErr.ExitCode() } else { result.ExitCode = -1 } } else { result.ExitCode = 0 } return result, err } func (s *Sandbox) isCommandAllowed(cmd string) bool { if len(s.cfg.AllowedCommands) == 0 { return true } base := filepath.Base(cmd) base = strings.TrimSuffix(base, ".exe") for _, allowed := range s.cfg.AllowedCommands { if strings.EqualFold(base, allowed) { return true } } return false } func (s *Sandbox) validateWorkDir(dir string) error { info, err := os.Stat(dir) if err != nil { return fmt.Errorf("work directory not accessible: %s: %w", dir, err) } if !info.IsDir() { return fmt.Errorf("not a directory: %s", dir) } if len(s.cfg.AllowedDirs) == 0 { return nil } absDir, err := filepath.Abs(dir) if err != nil { return fmt.Errorf("cannot resolve path: %w", err) } for _, allowed := range s.cfg.AllowedDirs { absAllowed, err := filepath.Abs(allowed) if err != nil { continue } if strings.HasPrefix(absDir, absAllowed) { return nil } } return fmt.Errorf("directory not in allowed list: %s", dir) } func (s *Sandbox) defaultWorkDir() string { if len(s.cfg.AllowedDirs) > 0 { return s.cfg.AllowedDirs[0] } wd, _ := os.Getwd() return wd } func (s *Sandbox) filteredEnv() []string { allowed := map[string]bool{ "PATH": true, "HOME": true, "USER": true, "USERNAME": true, "TMP": true, "TEMP": true, "TMPDIR": true, "LANG": true, "LC_ALL": true, "SHELL": true, "SYSTEMROOT": true, "WINDIR": true, "ProgramFiles": true, "GOPATH": true, "GOROOT": true, "GOPROXY": true, "NODE_PATH": true, "PYTHONPATH": true, "JAVA_HOME": true, "DOTNET_ROOT": true, "CARGO_HOME": true, "RUSTUP_HOME": true, } var filtered []string for _, e := range os.Environ() { kv := strings.SplitN(e, "=", 2) if len(kv) == 2 && allowed[kv[0]] { filtered = append(filtered, e) } } filtered = append(filtered, "CYRENE_SANDBOX=1") return filtered }