// Package tools implements the harness tool palette. v0 ships bash: an // allow-listed executor whose gate ports maki's permission semantics // (segment-by-segment command matching, deny beats allow, subshells and // command substitution denied outright in headless mode) without the // tree-sitter dependency; the full parse lands with the permission layer. package tools import ( "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "unicode/utf8" "ukrrs.com/mopac/harness/internal/llm" ) // ErrDenied marks gate rejections; the conductor surfaces them to the model // as tool results and counts them, they do not fail the turn. var ErrDenied = errors.New("bash: denied by permission gate") // BashOptions configures the bash tool. type BashOptions struct { WorkRoot string Allow []string Deny []string DefaultAllow bool Timeout time.Duration MaxOutputBytes int } // BashTool is the allow-listed bash executor. type BashTool struct { opts BashOptions } // NewBashTool validates options and prepares the work root. func NewBashTool(opts BashOptions) (*BashTool, error) { if opts.WorkRoot == "" { opts.WorkRoot = "." } abs, err := filepath.Abs(opts.WorkRoot) if err != nil { return nil, fmt.Errorf("bash tool: %w", err) } if err := os.MkdirAll(abs, 0o755); err != nil { return nil, fmt.Errorf("bash tool: %w", err) } opts.WorkRoot = abs if opts.Timeout <= 0 { opts.Timeout = 60 * time.Second } if opts.MaxOutputBytes <= 0 { opts.MaxOutputBytes = 100_000 } return &BashTool{opts: opts}, nil } // Definition returns the OpenAI function tool schema advertised to the model. func (b *BashTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolDefinition{ Name: "bash", Description: "Run a bash command from the task work root. Compound commands (&&, ||, ;, |) are checked segment by segment against an allow-list; command substitution, backticks, and subshells are always denied. Unmatched or denied commands fail.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{ "type": "string", "description": "The bash command to run.", }, }, "required": []string{"command"}, }, }, } } // Check returns nil when the command passes the gate. func (b *BashTool) Check(command string) error { if strings.TrimSpace(command) == "" { return fmt.Errorf("%w: empty command", ErrDenied) } if hasComplexConstruct(command) { return fmt.Errorf("%w: %q uses command substitution/subshell constructs, which are not decomposable", ErrDenied, command) } scopes := SplitCommand(command) if len(scopes) == 0 { return fmt.Errorf("%w: empty command", ErrDenied) } for _, s := range scopes { if p := matchAny(b.opts.Deny, s); p != "" { return fmt.Errorf("%w: segment %q matches deny rule %q", ErrDenied, s, p) } } for _, s := range scopes { if matchAny(b.opts.Allow, s) == "" && !b.opts.DefaultAllow { return fmt.Errorf("%w: segment %q not covered by any allow rule (default deny)", ErrDenied, s) } } return nil } // Run gate-checks and executes one bash tool call. On success (and on // non-zero exit) it returns the captured output; err is non-nil for gate // rejections (ErrDenied), timeouts, and spawn failures. func (b *BashTool) Run(ctx context.Context, args json.RawMessage) (string, error) { var in struct { Command string `json:"command"` } if len(args) == 0 { return "", fmt.Errorf("bash: missing arguments") } if err := json.Unmarshal(args, &in); err != nil { return "", fmt.Errorf("bash: bad arguments: %w", err) } if err := b.Check(in.Command); err != nil { return "", err } cctx, cancel := context.WithTimeout(ctx, b.opts.Timeout) defer cancel() cmd := exec.CommandContext(cctx, "bash", "-c", in.Command) cmd.Dir = b.opts.WorkRoot setPgroup(cmd) out, err := cmd.CombinedOutput() killGroup(cmd) // reap any grandchildren the direct kill missed res := b.truncate(out) if cctx.Err() == context.DeadlineExceeded { return res, fmt.Errorf("bash: timed out after %s", b.opts.Timeout) } if err != nil { return res, fmt.Errorf("bash: %w", err) } return res, nil } func (b *BashTool) truncate(out []byte) string { if len(out) <= b.opts.MaxOutputBytes { return string(out) } cut := b.opts.MaxOutputBytes for cut > 0 && !utf8.RuneStart(out[cut]) { cut-- } return string(out[:cut]) + fmt.Sprintf("\n...[truncated %d bytes]", len(out)-cut) } // hasComplexConstruct reports constructs the text-level decomposer cannot // safely split: command/process substitution, backticks, subshells. In // headless mode these deny (maki: force_prompt with no channel => deny). func hasComplexConstruct(cmd string) bool { return strings.Contains(cmd, "$(") || strings.Contains(cmd, "`") || strings.Contains(cmd, "<(") || strings.Contains(cmd, ">(") || strings.HasPrefix(strings.TrimSpace(cmd), "(") } // SplitCommand decomposes a compound command into its segment scopes by // splitting on &&, ||, ;, | and newlines outside quotes. Redirections stay // part of their segment (they must be explicitly allowed by text). func SplitCommand(cmd string) []string { var out []string var cur strings.Builder inSingle, inDouble := false, false runes := []rune(cmd) flush := func() { if s := strings.TrimSpace(cur.String()); s != "" { out = append(out, s) } cur.Reset() } for i := 0; i < len(runes); i++ { r := runes[i] switch { case inSingle: if r == '\'' { inSingle = false } cur.WriteRune(r) case inDouble: if r == '\\' && i+1 < len(runes) { cur.WriteRune(r) i++ cur.WriteRune(runes[i]) continue } if r == '"' { inDouble = false } cur.WriteRune(r) case r == '\'': inSingle = true cur.WriteRune(r) case r == '"': inDouble = true cur.WriteRune(r) case (r == '&' || r == '|') && i+1 < len(runes) && runes[i+1] == r: flush() i++ case r == ';' || r == '|' || r == '\n': flush() default: cur.WriteRune(r) } } flush() return out } // matchAny returns the first pattern in list that matches scope, or "". func matchAny(list []string, scope string) string { for _, p := range list { if ScopeMatches(p, scope) { return p } } return "" } // ScopeMatches ports maki's scope matcher. Order matters: universal // wildcards, then "/**" path prefixes, then " *" word-boundary command // prefixes, then bare "*" raw prefixes, then exact match. func ScopeMatches(pattern, scope string) bool { switch { case pattern == "*" || pattern == "/**" || pattern == "/*": return true case strings.HasSuffix(pattern, "/**"): return hasPathPrefix(scope, strings.TrimSuffix(pattern, "/**")) case strings.HasSuffix(pattern, " *"): head := strings.TrimSuffix(pattern, " *") return scope == head || strings.HasPrefix(scope, head+" ") case strings.HasSuffix(pattern, "*"): return strings.HasPrefix(scope, strings.TrimSuffix(pattern, "*")) default: return pattern == scope } } // hasPathPrefix reports whether scope names a path inside dir. v0 is lexical // (Clean + Abs); symlink-aware canonicalization lands with the permission // layer, as does write-root confinement. func hasPathPrefix(scope, dir string) bool { absScope, err := filepath.Abs(filepath.Clean(scope)) if err != nil { return false } absDir, err := filepath.Abs(filepath.Clean(dir)) if err != nil { return false } if absScope == absDir { return true } return strings.HasPrefix(absScope, absDir+string(filepath.Separator)) }