Files
zero/internal/tools/bash.go
KRATOSandClaude Opus 4.8 4eb8c0f389 Add shared model-agnostic tool-arg tolerance layer (aliases) for all models
internal/tools/argtolerance.go adds aliasedStringArg (alias-aware, type-strict, primary-key errors) and coerceStringSlice (best-effort list coercion). Every tool now accepts the key variants models emit: read_file/write_file/edit_file path->file/file_path/filename; content->contents/text/body/data; old_string->old/search/find; new_string->new/replace/replacement; apply_patch patch->diff; grep pattern->query/regex/search + path->dir/directory; glob pattern->glob/match/query + cwd->dir/path; list_directory path->directory/dir; bash command->cmd/script/shell. ask_user options/question coercion unified onto the shared helper. Type-strictness preserved (content:42 still errors). Serves the diverse multi-model userbase; build/vet/full-suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-06 23:59:20 +05:30

241 lines
6.8 KiB
Go

package tools
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
const defaultBashTimeoutMS = 120000
const maxBashTimeoutMS = 600000
type bashTool struct {
baseTool
workspaceRoot string
}
func NewBashTool(workspaceRoot string) Tool {
return bashTool{
baseTool: baseTool{
name: "bash",
description: "Execute a shell command inside the workspace after permission is granted.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"command": {Type: "string", Description: "Shell command to execute."},
"cwd": {Type: "string", Description: "Workspace directory to run the command in. Defaults to workspace root.", Default: "."},
"timeout_ms": {Type: "integer", Description: "Command timeout in milliseconds.", Default: defaultBashTimeoutMS, Minimum: intPtr(1), Maximum: intPtr(maxBashTimeoutMS)},
},
Required: []string{"command"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool bashTool) Run(ctx context.Context, args map[string]any) Result {
return tool.run(ctx, args, nil)
}
func (tool bashTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.run(ctx, args, engine)
}
func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
commandText, err := aliasedStringArg(args, []string{"command", "cmd", "script", "shell"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
cwd, err := stringArg(args, "cwd", ".", false)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
timeoutMS, err := intArg(args, "timeout_ms", defaultBashTimeoutMS, 1, maxBashTimeoutMS)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
// Pre-execution safety: refuse interactive commands (editors, pagers, REPLs,
// remote shells, etc.) that would hang the non-interactive agent until the
// timeout fires. This runs before the command is built or launched.
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtime.GOOS); interactive.Interactive {
return interactiveBlockResult(interactive)
}
absoluteCwd, relativeCwd, err := resolveWorkspacePath(tool.workspaceRoot, cwd)
if err != nil {
return errorResult("Error running bash: " + err.Error())
}
commandCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMS)*time.Millisecond)
defer cancel()
meta := map[string]string{
"cwd": relativeCwd,
"timeout_ms": strconv.Itoa(timeoutMS),
}
command, plan, err := buildBashCommand(commandCtx, commandText, absoluteCwd, engine)
if err != nil {
meta["exit_code"] = "-1"
return Result{
Status: StatusError,
Output: "Error preparing sandboxed bash command: " + err.Error(),
Meta: meta,
}
}
addSandboxMeta(meta, plan)
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
err = command.Run()
exitCode := commandExitCode(err)
meta["exit_code"] = strconv.Itoa(exitCode)
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
return Result{
Status: StatusError,
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
Meta: meta,
}
}
if err != nil {
if exitCode < 0 {
return Result{
Status: StatusError,
Output: "Error executing command: " + err.Error(),
Meta: meta,
}
}
return Result{
Status: StatusError,
Output: formatBashOutput(stdout.String(), stderr.String(), exitCode),
Meta: meta,
}
}
return Result{
Status: StatusOK,
Output: formatBashOutput(stdout.String(), stderr.String(), exitCode),
Meta: meta,
}
}
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
spec := zeroSandbox.CommandSpec{
Name: shellExecutable(),
Args: shellArguments(commandText),
Dir: absoluteCwd,
}
if engine != nil {
return engine.CommandContext(ctx, spec)
}
plan := zeroSandbox.CommandPlan{
Backend: zeroSandbox.Backend{
Name: zeroSandbox.BackendPolicyOnly,
Message: "sandbox engine not provided",
},
Wrapped: false,
Name: spec.Name,
Args: spec.Args,
Dir: spec.Dir,
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
return command, plan, nil
}
func addSandboxMeta(meta map[string]string, plan zeroSandbox.CommandPlan) {
if plan.Backend.Name == "" {
return
}
meta["sandbox_backend"] = string(plan.Backend.Name)
meta["sandbox_wrapped"] = strconv.FormatBool(plan.Wrapped)
if plan.Backend.Message != "" {
meta["sandbox_message"] = plan.Backend.Message
}
if plan.SandboxDir != "" {
meta["sandbox_cwd"] = plan.SandboxDir
}
}
// interactiveBlockResult builds the structured tool Result returned when a
// command is refused before execution because it would hang the agent. The
// violation is surfaced both in Output (clearly delimited) and in Meta/Display
// so downstream consumers and the TUI can render it consistently.
func interactiveBlockResult(detection zeroSandbox.InteractiveCommandResult) Result {
message := fmt.Sprintf(
"Error: Blocked interactive command %q before execution: %s. This would hang the non-interactive agent.\nSuggestion: %s",
detection.Command, detection.Reason, detection.Suggestion,
)
return Result{
Status: StatusError,
Output: message,
Meta: map[string]string{
"exit_code": "-1",
"safety_block": "interactive_command",
"safety_cmd": detection.Command,
},
Display: Display{
Summary: "Blocked interactive command: " + detection.Command,
Kind: "shell",
},
}
}
func shellExecutable() string {
if runtime.GOOS == "windows" {
return "cmd.exe"
}
return "/bin/sh"
}
func shellArguments(command string) []string {
if runtime.GOOS == "windows" {
return []string{"/d", "/s", "/c", command}
}
return []string{"-c", command}
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode()
}
return -1
}
func formatBashOutput(stdout string, stderr string, exitCode int) string {
parts := []string{}
stdout = strings.TrimRight(stdout, "\r\n")
stderr = strings.TrimRight(stderr, "\r\n")
if stdout != "" {
parts = append(parts, "stdout:\n"+stdout)
}
if stderr != "" {
parts = append(parts, "stderr:\n"+stderr)
}
if exitCode != 0 {
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
}
if len(parts) == 0 {
return "Command completed with no output."
}
return strings.Join(parts, "\n")
}