mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* fix(tools): block MSYS coreutils under Windows sandbox Git for Windows usr\bin coreutils (head, grep, cat, ...) are MSYS PE binaries that fail with ACCESS_DENIED and cygheap/signal-pipe errors when Zero runs them in the write-restricted Windows sandbox. Detect and block these commands before execution, surface MSYS runtime failures from stderr, steer agents toward native Zero tools or require_escalated, and exclude MSYS-prone names from known-safe command tails. Refs #458 * fix(tools): anchor the last MSYS failure heuristic to MSYS markers CodeRabbit review feedback: the win32-error-5 plus terminating check had no MSYS-specific anchor, so unrelated access-denied failures could be mislabeled as windows_msys_sandbox. Require an MSYS marker like the neighboring checks do. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(tools): let escalation bypass the MSYS guard and make detection quote-aware Addresses jatmn's review on PR #476: - bash and exec_command called detectShellCommandIssue before resolving sandbox_permissions into a command engine, so a require_escalated retry of a blocked command was still hard-blocked by the same preflight check. Resolve the command engine first and only bypass the windows_msys_sandbox issue kind when that resolves to true unsandboxed execution; other issue kinds (real cmd.exe syntax problems) still block regardless of escalation. - Unify all MSYS-prone command name checks (MsysProneCommandName, the preflight scan, and transitively command_prefix.go's known-safe guard) behind one windowsMsysProneNames set. This fixes expr (in the name set but missing from the old regex alternations, so never proactively flagged) and ls (hit the older windows_shell_syntax branch first, so it got different metadata than every other MSYS-prone name). - Make the preflight scan quote-aware: split the command into cmd.exe- operator-separated segments respecting double-quote grouping, and only check the first word of each segment instead of scanning raw text anywhere. Fixes false positives on quoted text like commit messages or PR comment bodies that merely mention a coreutil name. * test(tools): decouple MSYS-guard bypass tests from real command output TestBashToolRequireEscalatedMsysGuard and TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval asserted shell_issue == "" to prove the require_escalated bypass worked. Once bypassed, "cat somefile.txt" actually runs unsandboxed, and its real, PATH-dependent output could trip the unrelated post-execution detectShellOutputIssue heuristic, failing the assertion for reasons unrelated to the preflight guard under test. Assert on the exit_code "-1" sentinel (set only by shellIssueBlockResult) instead, which directly reflects whether the preflight guard blocked the command. * fix(tools): anchor MSYS path detection to command position, fix prefix approval gap Address jatmn's latest review on PR #476: - shell_runtime.go: the explicit MSYS binary-path check (usr\bin\, mingw64\bin\, msys-2.0.dll, cygwin1.dll) ran against the raw command string, so a quoted mention (e.g. a commit message describing a usr\bin\head.exe failure) was still hard-blocked. It now runs against the first word of each operator-separated segment, same as the coreutil-name check. windowsCommandSegments also stopped treating ; as a command separator (cmd.exe does not split on ;, unlike bash) and now respects cmd.exe's ^ escape character, so escaped operators like echo ^| head are no longer misread as a real head invocation. - command_prefix.go: proposedCommandPrefix could offer to approve a single segment (e.g. "ps aux" in "ps aux | head -5") without checking that every other segment was independently safe. Since approving a prefix escalates the whole command to bypass the sandbox, an MSYS-prone or otherwise unsafe segment could ride along uncovered. It now only proposes a segment as the prefix when every other segment in the command is known-safe on its own. Added regression tests for quoted MSYS path mentions, caret-escaped operators, semicolon text, and the prefix-approval gap. Co-Authored-By: Claude Sonnet 5 <[email protected]> * fix(tools): treat redirection as a command boundary, stop using command text as output evidence Addresses jatmn's latest review on PR #476: - firstCommandWord only split on whitespace, so cmd.exe redirection attached directly to a command name (head>out.txt, cat<in.txt, grep>matches.txt) was read as one word and never matched against the MSYS-prone name set. It now also stops at < and >. - detectShellOutputIssue scanned command+output together, so a command whose own argument text merely quoted an MSYS failure string (e.g. a gh pr comment --body describing the bug) could be mislabeled after running even though the real stdout/stderr was unrelated. Removed the command parameter entirely from detectShellOutputIssue and both call sites (bash.go, exec_command.go), so the command line can never be used as post-execution evidence again. * fix(tools): remove unused truncateHeadTail wrapper CodeRabbit nitpick: truncateHeadTailWithTotal is called directly everywhere, so this wrapper is dead code that trips golangci-lint's unused check and fails CI. --------- Co-authored-by: Claude Fable 5 <[email protected]>
566 lines
22 KiB
Go
566 lines
22 KiB
Go
package tools
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os/exec"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
||
"github.com/Gitlawb/zero/internal/secrets"
|
||
)
|
||
|
||
const defaultBashTimeoutMS = 120000
|
||
const maxBashTimeoutMS = 600000
|
||
|
||
type bashTool struct {
|
||
baseTool
|
||
workspaceRoot string
|
||
scope PathScope
|
||
}
|
||
|
||
func NewBashTool(workspaceRoot string) Tool {
|
||
return NewScopedBashTool(workspaceRoot, nil)
|
||
}
|
||
|
||
func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
|
||
shellGuidance := shellGuidanceForGOOS(runtime.GOOS)
|
||
return bashTool{
|
||
baseTool: baseTool{
|
||
name: "bash",
|
||
description: "Execute a shell command inside the workspace (or an explicitly granted extra directory) after permission is granted. " + shellGuidance,
|
||
parameters: Schema{
|
||
Type: "object",
|
||
Properties: map[string]PropertySchema{
|
||
"command": {Type: "string", Description: "Shell command to execute using the host shell. " + shellGuidance},
|
||
"cwd": {Type: "string", Description: "Directory to run the command in. Relative paths stay in the workspace; use an absolute path to run in a granted extra directory. Defaults to workspace root. Prefer cwd over cd when changing directories.", Default: "."},
|
||
"timeout_ms": {Type: "integer", Description: "Command timeout in milliseconds.", Default: defaultBashTimeoutMS, Minimum: intPtr(1), Maximum: intPtr(maxBashTimeoutMS)},
|
||
"sandbox_permissions": {Type: "string", Enum: []string{string(SandboxPermissionsUseDefault), string(SandboxPermissionsWithAdditionalPermissions), string(SandboxPermissionsRequireEscalated)}, Description: "Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions` for sandboxed file/network access, or `require_escalated` only when the command must run outside the sandbox, such as host/global process, socket, service, or desktop state hidden by sandbox namespaces.", Default: string(SandboxPermissionsUseDefault)},
|
||
"additional_permissions": {
|
||
Type: "object",
|
||
Description: "Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`.",
|
||
Properties: additionalPermissionsProperties(),
|
||
},
|
||
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
|
||
"prefix_rule": {Type: "array", Items: &PropertySchema{Type: "string"}, Description: "Reusable approval prefix for this command, only with `sandbox_permissions: \"require_escalated\"`; keep it narrow, for example [\"git\", \"pull\"]."},
|
||
},
|
||
Required: []string{"command"},
|
||
AdditionalProperties: false,
|
||
},
|
||
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
|
||
},
|
||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||
scope: scope,
|
||
}
|
||
}
|
||
|
||
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())
|
||
}
|
||
sandboxPermissions, err := sandboxPermissionsArg(args)
|
||
if err != nil {
|
||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||
}
|
||
// Resolve the command engine before the MSYS preflight check so an
|
||
// approved require_escalated call (commandEngine == nil, truly
|
||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||
// hard-blocked by the same check it was meant to escalate past.
|
||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||
return shellIssueBlockResult(*issue)
|
||
}
|
||
|
||
// 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 := resolveScopedPath(tool.workspaceRoot, tool.scope, 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),
|
||
}
|
||
if commandEngine == nil && sandboxPermissions == SandboxPermissionsRequireEscalated {
|
||
meta["sandbox_permissions"] = string(SandboxPermissionsRequireEscalated)
|
||
}
|
||
command, plan, err := buildBashCommand(commandCtx, commandText, absoluteCwd, commandEngine)
|
||
if err != nil {
|
||
meta["exit_code"] = "-1"
|
||
return Result{
|
||
Status: StatusError,
|
||
Output: "Error preparing sandboxed bash command: " + err.Error(),
|
||
Meta: meta,
|
||
}
|
||
}
|
||
defer plan.Cleanup()
|
||
addSandboxMeta(meta, plan)
|
||
|
||
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
|
||
// can't grow Zero's memory before truncation: only the head+tail each stream
|
||
// will ever surface to the model are retained, the middle is discarded as it
|
||
// streams, and the true size is counted for the truncation marker.
|
||
stdout := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||
command.Stdout = stdout
|
||
command.Stderr = stderr
|
||
|
||
// Kill the shell as a process group on timeout and bound the post-kill I/O
|
||
// wait, so a backgrounded child cannot outlive the command or hang Run().
|
||
hardenProcessLifetime(command)
|
||
|
||
// Capture sandbox denials when the plan opted in (macOS + Policy.MonitorDenials).
|
||
// A no-op when MonitorTag is empty, so the default path is unchanged.
|
||
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
|
||
err = command.Run()
|
||
exitCode := commandExitCode(err)
|
||
meta["exit_code"] = strconv.Itoa(exitCode)
|
||
stdoutText := stdout.retained()
|
||
stderrRetained := stderr.retained()
|
||
stderrText := appendSandboxBlocks(stderrRetained, monitor.Stop())
|
||
// Sandbox blocks are extra model-visible stderr bytes appended after capture;
|
||
// count them toward the true total so the budget/marker stay accurate.
|
||
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
|
||
|
||
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,
|
||
}
|
||
}
|
||
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
|
||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||
return Result{
|
||
Status: StatusError,
|
||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||
Truncated: truncated,
|
||
Meta: meta,
|
||
}
|
||
}
|
||
|
||
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
|
||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||
if meta[SandboxLikelyDeniedMeta] == "true" {
|
||
return Result{
|
||
Status: StatusError,
|
||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||
Truncated: truncated,
|
||
Meta: meta,
|
||
}
|
||
}
|
||
return Result{
|
||
Status: StatusOK,
|
||
Output: formatBashOutput(outText, errText, exitCode),
|
||
Truncated: truncated,
|
||
Meta: meta,
|
||
}
|
||
}
|
||
|
||
func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) *zeroSandbox.Engine {
|
||
if sandboxPermissions == SandboxPermissionsRequireEscalated && (engine == nil || engine.UnsandboxedExecutionAllowed()) {
|
||
return nil
|
||
}
|
||
return engine
|
||
}
|
||
|
||
// msysGuardBypassed reports whether a windows_msys_sandbox preflight issue no
|
||
// longer applies because sandbox_permissions: require_escalated actually
|
||
// resolved to unsandboxed, host-level execution (commandEngine is nil only
|
||
// then, per commandEngineForSandboxPermissions). MSYS coreutils fail because
|
||
// of the sandbox's restricted token/handles, so running outside it removes
|
||
// the failure mode this guard exists for. Any other issue kind (e.g. a real
|
||
// cmd.exe syntax problem) still blocks regardless of escalation, since
|
||
// running outside the sandbox does not fix a syntax error.
|
||
func msysGuardBypassed(issue *shellIssue, commandEngine *zeroSandbox.Engine) bool {
|
||
return issue.Kind == windowsMsysSandboxKind && commandEngine == nil
|
||
}
|
||
|
||
// appendSandboxBlocks appends a <sandbox_blocks> block listing the denials
|
||
// the sandbox log monitor captured, so the model can see what was blocked. With no
|
||
// blocks the stderr is returned unchanged.
|
||
func appendSandboxBlocks(stderr string, blocks []string) string {
|
||
if len(blocks) == 0 {
|
||
return stderr
|
||
}
|
||
var builder strings.Builder
|
||
builder.WriteString("<sandbox_blocks>\n")
|
||
for _, block := range blocks {
|
||
builder.WriteString(block)
|
||
builder.WriteString("\n")
|
||
}
|
||
builder.WriteString("</sandbox_blocks>")
|
||
if strings.TrimSpace(stderr) == "" {
|
||
return builder.String()
|
||
}
|
||
return stderr + "\n" + builder.String()
|
||
}
|
||
|
||
func shellIssueBlockResult(issue shellIssue) Result {
|
||
return Result{
|
||
Status: StatusError,
|
||
Output: appendShellIssueHint("", issue),
|
||
Meta: map[string]string{
|
||
"exit_code": "-1",
|
||
"shell_issue": issue.Kind,
|
||
},
|
||
Display: Display{
|
||
Summary: issue.Message,
|
||
Kind: "shell",
|
||
},
|
||
}
|
||
}
|
||
|
||
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
|
||
// commandText. On Windows, when the command is not wrapped by the sandbox
|
||
// engine (plan.Wrapped == false), it also overrides the child's raw command
|
||
// line so commandText reaches cmd.exe unescaped; see
|
||
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
|
||
// gets the same treatment inside the sandboxed runner process itself
|
||
// (internal/sandbox/windows_process_windows.go), since that command line is
|
||
// built there, not here.
|
||
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 {
|
||
command, plan, err := engine.CommandContext(ctx, spec)
|
||
if err == nil {
|
||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
|
||
}
|
||
return command, plan, err
|
||
}
|
||
plan := zeroSandbox.CommandPlan{
|
||
Backend: zeroSandbox.Backend{
|
||
Name: zeroSandbox.BackendUnavailable,
|
||
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
|
||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
|
||
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)
|
||
if plan.TargetBackend != "" {
|
||
meta["sandbox_target_backend"] = string(plan.TargetBackend)
|
||
}
|
||
meta["sandbox_wrapped"] = strconv.FormatBool(plan.Wrapped)
|
||
if plan.EnforcementLevel != "" {
|
||
meta["sandbox_enforcement_level"] = string(plan.EnforcementLevel)
|
||
}
|
||
if plan.DowngradeReason != "" {
|
||
meta["sandbox_downgrade_reason"] = plan.DowngradeReason
|
||
}
|
||
meta["sandbox_requires_platform"] = strconv.FormatBool(plan.RequiresPlatformSandbox)
|
||
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
|
||
// block 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 zeroSandbox.WindowsShellArgs(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")
|
||
// Redact high-confidence secrets a command may have printed, so they are not
|
||
// echoed back into the model context (additive to the configured-key scrub
|
||
// done at the registry boundary).
|
||
stdout, outFindings := secrets.Redact(stdout)
|
||
stderr, errFindings := secrets.Redact(stderr)
|
||
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 n := len(outFindings) + len(errFindings); n > 0 {
|
||
parts = append(parts, fmt.Sprintf("[zero] redacted %d likely secret(s) from this output before showing it.", n))
|
||
}
|
||
if len(parts) == 0 {
|
||
return "Command completed with no output."
|
||
}
|
||
return strings.Join(parts, "\n")
|
||
}
|
||
|
||
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model (32 KiB
|
||
// ≈ 8k tokens per stream). bash is the one tool that can emit unbounded output
|
||
// (`cat large.log`, `find /`, verbose test runs); a chatty build log at the old
|
||
// 96 KiB budget injected ~24k tokens per call and was then re-billed on every
|
||
// following turn. Head+tail truncation keeps both the start and the end of an
|
||
// oversized stream, since build/test failures usually surface at the tail, and
|
||
// the full captured text is spilled to disk so nothing is lost — the model
|
||
// greps/reads the spill instead of re-running the command.
|
||
const bashOutputBudgetBytes = 32 * 1024
|
||
|
||
// bashCaptureBudgetBytes bounds the head and tail retained IN MEMORY per
|
||
// stream during capture. Deliberately larger than the emit budget: the extra
|
||
// retained bytes never reach the transcript, but they make the spill file —
|
||
// the model's recovery path — cover 6× more of the output.
|
||
const bashCaptureBudgetBytes = 96 * 1024
|
||
|
||
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
|
||
// keeping the head and tail of anything larger, and records raw/emitted byte
|
||
// counts plus a truncated flag in meta (mirroring outputBudgetMeta's shape for
|
||
// the read/search tools). Detection that needs the full output (sandbox-denial
|
||
// scanning) must run on the raw strings before this is applied.
|
||
func budgetBashOutput(stdout string, stderr string, meta map[string]string) (string, string, bool) {
|
||
return budgetBashCapture(stdout, len(stdout), stderr, len(stderr), meta)
|
||
}
|
||
|
||
// budgetBashCapture is budgetBashOutput for the streaming-capture path: outTotal
|
||
// and errTotal are the true byte counts (from boundedBuffer.total), which may
|
||
// exceed the retained strings when the middle was dropped during capture. Meta's
|
||
// raw_bytes therefore reflects everything the command produced, not just what was
|
||
// kept in memory.
|
||
func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, meta map[string]string) (string, string, bool) {
|
||
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
|
||
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
|
||
truncated := outTrunc || errTrunc
|
||
if truncated {
|
||
if spillPath := spillBashStreams(out, outTotal, errStr, errTotal); spillPath != "" {
|
||
hint := "\n[zero] captured output saved to " + spillPath + " (grep or read_file it instead of re-running)"
|
||
if errTrunc {
|
||
errText += hint
|
||
} else {
|
||
outText += hint
|
||
}
|
||
if meta != nil {
|
||
meta["spill_path"] = spillPath
|
||
}
|
||
}
|
||
}
|
||
if meta != nil {
|
||
emitted := len(outText) + len(errText)
|
||
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
|
||
meta["emitted_bytes"] = strconv.Itoa(emitted)
|
||
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(emitted))
|
||
if truncated {
|
||
meta["truncated"] = "true"
|
||
}
|
||
}
|
||
return outText, errText, truncated
|
||
}
|
||
|
||
// spillBashStreams writes the retained (capture-bounded) stdout and stderr to
|
||
// the spill directory as one sectioned file and returns its path, or "" when
|
||
// spilling fails. The spill holds up to bashCaptureBudgetBytes of head and
|
||
// tail per stream — everything zero kept in memory, which is more than the
|
||
// emit budget shows the model but not necessarily the whole output. When the
|
||
// capture itself dropped the middle of a stream (total exceeds the retained
|
||
// bytes), a gap marker is inserted at the head/tail junction so the spilled
|
||
// log never reads as contiguous when it is not.
|
||
func spillBashStreams(stdout string, stdoutTotal int, stderr string, stderrTotal int) string {
|
||
stdout = sectionWithCaptureGap(stdout, stdoutTotal)
|
||
stderr = sectionWithCaptureGap(stderr, stderrTotal)
|
||
var combined strings.Builder
|
||
combined.Grow(len(stdout) + len(stderr) + 64)
|
||
combined.WriteString("### stdout\n")
|
||
combined.WriteString(stdout)
|
||
if !strings.HasSuffix(stdout, "\n") {
|
||
combined.WriteString("\n")
|
||
}
|
||
combined.WriteString("### stderr\n")
|
||
combined.WriteString(stderr)
|
||
return spillTruncatedOutput("bash", combined.String())
|
||
}
|
||
|
||
// sectionWithCaptureGap marks the point where boundedBuffer dropped the middle
|
||
// of a stream. When total exceeds the retained bytes, the retained text is the
|
||
// frozen head (bashCaptureBudgetBytes, always full once overflow happened)
|
||
// followed immediately by the rolling tail — the junction sits at the head cap,
|
||
// snapped back to a rune boundary.
|
||
func sectionWithCaptureGap(text string, total int) string {
|
||
if total <= len(text) || len(text) <= bashCaptureBudgetBytes {
|
||
return text
|
||
}
|
||
head := utf8Prefix(text, bashCaptureBudgetBytes)
|
||
marker := fmt.Sprintf("\n[zero] capture gap: %d bytes omitted from the middle of this stream\n", total-len(text))
|
||
return head + marker + text[len(head):]
|
||
}
|
||
|
||
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
|
||
// and tailCap bytes from the end of a stream while counting the total written, so
|
||
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
|
||
// memory: the middle is discarded as it arrives instead of buffered whole and then
|
||
// truncated. total records the full size for the truncation marker even though the
|
||
// middle is never held. Not safe for concurrent writes; exec drives Stdout and
|
||
// Stderr from separate goroutines, so each stream gets its own buffer.
|
||
type boundedBuffer struct {
|
||
head []byte
|
||
headCap int
|
||
tail []byte
|
||
tailCap int
|
||
total int
|
||
}
|
||
|
||
func newBoundedBuffer(headCap, tailCap int) *boundedBuffer {
|
||
return &boundedBuffer{headCap: headCap, tailCap: tailCap}
|
||
}
|
||
|
||
func (b *boundedBuffer) Write(p []byte) (int, error) {
|
||
n := len(p)
|
||
b.total += n
|
||
// Fill the head until it reaches headCap; the head is written once and frozen.
|
||
if len(b.head) < b.headCap {
|
||
take := b.headCap - len(b.head)
|
||
if take > len(p) {
|
||
take = len(p)
|
||
}
|
||
b.head = append(b.head, p[:take]...)
|
||
p = p[take:]
|
||
}
|
||
// Anything past the head feeds a rolling tail that keeps only the last tailCap
|
||
// bytes. Compact once it grows past 2×tailCap so the append stays amortized O(1)
|
||
// while memory never exceeds ~2×tailCap.
|
||
if len(p) > 0 && b.tailCap > 0 {
|
||
b.tail = append(b.tail, p...)
|
||
if len(b.tail) > 2*b.tailCap {
|
||
b.tail = append(b.tail[:0], b.tail[len(b.tail)-b.tailCap:]...)
|
||
}
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// retained returns the kept head+tail bytes (marker-less) as a string. The junction
|
||
// between head and tail lands in the middle, which the display budget trims away;
|
||
// callers that need the true size read total separately.
|
||
func (b *boundedBuffer) retained() string {
|
||
if len(b.tail) > b.tailCap {
|
||
// Not yet compacted since the last overflow; expose only the last tailCap.
|
||
return string(b.head) + string(b.tail[len(b.tail)-b.tailCap:])
|
||
}
|
||
return string(b.head) + string(b.tail)
|
||
}
|
||
|
||
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
|
||
// the full original byte count — for the "N bytes omitted" marker and the raw
|
||
// count. total may exceed len(value) when the middle was already discarded during
|
||
// bounded capture (boundedBuffer): value then holds only the retained head+tail,
|
||
// and this trims it to the display budget while still reporting the true total.
|
||
func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int, bool) {
|
||
if maxBytes <= 0 || total <= maxBytes {
|
||
return value, total, false
|
||
}
|
||
marker := fmt.Sprintf("\n[zero] output truncated: %d bytes omitted from the middle — redirect to a file and read_file a range for the full text\n", total-maxBytes)
|
||
budget := maxBytes - len(marker)
|
||
if budget < 0 {
|
||
budget = 0
|
||
}
|
||
head := budget / 2
|
||
tail := budget - head
|
||
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
|
||
}
|
||
|
||
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||
output := formatBashOutput(stdout, stderr, exitCode)
|
||
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||
meta["shell_issue"] = issue.Kind
|
||
output = appendShellIssueHint(output, *issue)
|
||
}
|
||
return output
|
||
}
|