Files
zero/internal/tools/exec_command.go
euxaristiaandClaude Fable 5 81aad58d97 fix(tools): Block MSYS coreutils under Windows sandbox (#476)
* 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]>
2026-07-06 08:03:32 +08:00

988 lines
31 KiB
Go

package tools
import (
"context"
"fmt"
"io"
"os/exec"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
const (
ExecCommandToolName = "exec_command"
WriteStdinToolName = "write_stdin"
defaultExecYieldTimeMS = 10000
defaultPollYieldTimeMS = 5000
maxExecYieldTimeMS = 30000
maxPollYieldTimeMS = 300000
defaultMaxOutputTokens = 10000
maxExecOutputTokenRequest = 200000
completedSessionRetention = 30 * time.Second
maxExecSessions = 64
recentExecOutputBytes = 4096
// maxExecOutputBufferBytes caps the undrained output an unpolled session can
// accumulate. Without a cap, a long-lived background session nobody polls
// again (e.g. a dev server left running after its initiating run was
// cancelled) grows this buffer forever as long as the process keeps writing
// output, with no ceiling — this previously ran a session's memory into the
// tens of gigabytes over several hours and got the whole zero process
// OOM-killed by the OS.
maxExecOutputBufferBytes = 2 * 1024 * 1024
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
execOutputBufferTruncatedMessage = "[zero] output buffer truncated: undrained output exceeded 2MiB, oldest output dropped"
)
type execSessionManager struct {
mu sync.Mutex
nextID int
sessions map[int]*execSession
completedRetention time.Duration
maxSessions int
}
func newExecSessionManager() *execSessionManager {
return &execSessionManager{
nextID: 1000,
sessions: make(map[int]*execSession),
completedRetention: completedSessionRetention,
maxSessions: maxExecSessions,
}
}
var defaultExecSessionManager = newExecSessionManager()
func (manager *execSessionManager) allocateID() int {
manager.mu.Lock()
defer manager.mu.Unlock()
id := manager.nextID
manager.nextID++
return id
}
func (manager *execSessionManager) store(session *execSession) {
manager.mu.Lock()
var pruned *execSession
removePruned := false
if manager.maxSessions > 0 && len(manager.sessions) >= manager.maxSessions {
pruned = manager.sessionToPruneLocked()
if pruned != nil {
removePruned = pruned.doneClosed()
if removePruned {
delete(manager.sessions, pruned.id)
}
}
}
manager.sessions[session.id] = session
manager.mu.Unlock()
if pruned != nil && !removePruned {
pruned.output.Write([]byte(execSessionEvictedMessage))
pruned.terminate()
}
}
func (manager *execSessionManager) get(id int) (*execSession, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
session, ok := manager.sessions[id]
return session, ok
}
func (manager *execSessionManager) remove(id int) {
manager.mu.Lock()
defer manager.mu.Unlock()
delete(manager.sessions, id)
}
func (manager *execSessionManager) removeCompletedLater(session *execSession) {
retention := manager.completedRetention
go func() {
<-session.done
if retention > 0 {
timer := time.NewTimer(retention)
<-timer.C
}
manager.remove(session.id)
}()
}
func (manager *execSessionManager) len() int {
manager.mu.Lock()
defer manager.mu.Unlock()
return len(manager.sessions)
}
func (manager *execSessionManager) sessionToPruneLocked() *execSession {
if len(manager.sessions) == 0 {
return nil
}
// Snapshot each session's lastUsedAt UNDER session.mu before sorting: touch()
// writes lastUsedAt under session.mu, so reading it here (under manager.mu only)
// was a data race on a multi-word time.Time. Lock order stays manager.mu →
// session.mu (no path takes them the other way). (AUDIT-L15)
type sessionAge struct {
session *execSession
last time.Time
}
ages := make([]sessionAge, 0, len(manager.sessions))
for _, session := range manager.sessions {
ages = append(ages, sessionAge{session: session, last: session.lastUsed()})
}
sort.Slice(ages, func(i, j int) bool {
return ages[i].last.Before(ages[j].last)
})
for _, a := range ages {
if a.session.doneClosed() {
return a.session
}
}
if len(ages) <= 8 {
return nil
}
return ages[0].session
}
// lastUsed returns the session's last-used time under its own lock, so the prune
// comparator never races touch()'s write to lastUsedAt.
func (session *execSession) lastUsed() time.Time {
session.mu.Lock()
defer session.mu.Unlock()
return session.lastUsedAt
}
func (manager *execSessionManager) list() []ExecSessionSnapshot {
manager.mu.Lock()
sessions := make([]*execSession, 0, len(manager.sessions))
for _, session := range manager.sessions {
if !session.doneClosed() {
sessions = append(sessions, session)
}
}
manager.mu.Unlock()
snapshots := make([]ExecSessionSnapshot, 0, len(sessions))
for _, session := range sessions {
snapshots = append(snapshots, session.snapshot())
}
sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].ID < snapshots[j].ID
})
return snapshots
}
func (manager *execSessionManager) stop(id int) bool {
session, ok := manager.get(id)
if !ok {
return false
}
session.terminate()
return true
}
func (manager *execSessionManager) stopAll() []int {
manager.mu.Lock()
sessions := make([]*execSession, 0, len(manager.sessions))
for _, session := range manager.sessions {
if !session.doneClosed() {
sessions = append(sessions, session)
}
}
manager.mu.Unlock()
ids := make([]int, 0, len(sessions))
for _, session := range sessions {
session.terminate()
ids = append(ids, session.id)
}
waitForExecSessions(sessions, execSessionStopTimeout)
sort.Ints(ids)
return ids
}
func waitForExecSessions(sessions []*execSession, timeout time.Duration) {
if timeout <= 0 || len(sessions) == 0 {
return
}
deadline := time.Now().Add(timeout)
for _, session := range sessions {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
select {
case <-session.done:
case <-timer.C:
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
type ExecSessionSnapshot struct {
ID int
Command string
Cwd string
RelativeCwd string
StartedAt time.Time
LastUsedAt time.Time
TTY bool
Status string
ExitCode *int
RecentOutput string
// OutputTruncated reflects the buffer's last-known truncation state (see
// execOutputBuffer.peekTruncated), so a session listing (/ps) still shows
// this even if the session is reaped before ever being polled again.
OutputTruncated bool
}
type ExecSessionController interface {
ExecSessions() []ExecSessionSnapshot
StopExecSession(id int) bool
StopAllExecSessions() []int
}
type execSession struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
plan zeroSandbox.CommandPlan
cancel context.CancelFunc
stdin io.WriteCloser
cleanup func()
output *execOutputBuffer
doneOnce sync.Once
done chan struct{}
mu sync.Mutex
exitCode *int
waitErr error
}
func (session *execSession) markDone(err error, exitCode int) {
session.mu.Lock()
session.waitErr = err
session.exitCode = &exitCode
session.mu.Unlock()
session.doneOnce.Do(func() { close(session.done) })
}
func (session *execSession) doneClosed() bool {
select {
case <-session.done:
return true
default:
return false
}
}
func (session *execSession) exitStatus() (int, bool) {
session.mu.Lock()
defer session.mu.Unlock()
if session.exitCode == nil {
return 0, false
}
return *session.exitCode, true
}
func (session *execSession) touch() {
session.mu.Lock()
session.lastUsedAt = time.Now()
session.mu.Unlock()
}
func (session *execSession) terminate() {
if session.cancel != nil {
session.cancel()
}
}
func (session *execSession) snapshot() ExecSessionSnapshot {
session.mu.Lock()
startedAt := session.startedAt
lastUsedAt := session.lastUsedAt
exitCode := session.exitCode
var copiedExit *int
if exitCode != nil {
value := *exitCode
copiedExit = &value
}
session.mu.Unlock()
status := "running"
if copiedExit != nil {
status = "exited"
}
return ExecSessionSnapshot{
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
OutputTruncated: session.output.peekTruncated(),
}
}
type execOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
}
func newExecOutputBuffer() *execOutputBuffer {
return &execOutputBuffer{notify: make(chan struct{}, 1)}
}
func (buffer *execOutputBuffer) Write(p []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, p...)
if len(buffer.data) > maxExecOutputBufferBytes {
buffer.data = buffer.data[len(buffer.data)-maxExecOutputBufferBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, p...)
if len(buffer.recent) > recentExecOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentExecOutputBytes:]
}
buffer.mu.Unlock()
select {
case buffer.notify <- struct{}{}:
default:
}
return len(p), nil
}
func (buffer *execOutputBuffer) recentString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return string(buffer.recent)
}
func (buffer *execOutputBuffer) drainString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
if len(buffer.data) == 0 {
return ""
}
out := string(buffer.data)
buffer.data = nil
return out
}
// consumeTruncated reports whether the buffer has dropped output to stay
// within maxExecOutputBufferBytes since the last call, resetting the flag.
// Kept as an out-of-band signal rather than text embedded in drainString's
// result: an earlier version prefixed a notice directly into the drained
// string, but that notice always sat ~maxExecOutputBufferBytes (2MiB) before
// the end of the combined output, far outside the byte-budget head/tail
// window truncateExecOutput keeps afterward (at most 400KB even at the
// tool's maximum max_output_tokens) — so the notice was reliably swallowed
// or chopped by that second, unrelated truncation pass. Surfacing it as a
// separate bool lets the caller report it regardless of where in the byte
// stream the overflow happened.
func (buffer *execOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
truncated := buffer.truncated
buffer.truncated = false
return truncated
}
// peekTruncated reports the buffer's last-known truncation state without
// clearing it, for status views (e.g. /ps) that shouldn't consume the signal
// a subsequent poll is still meant to report via consumeTruncated.
func (buffer *execOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
type execCommandTool struct {
baseTool
workspaceRoot string
scope PathScope
manager *execSessionManager
}
func NewExecCommandTool(workspaceRoot string, manager *execSessionManager) Tool {
return NewScopedExecCommandTool(workspaceRoot, nil, manager)
}
func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *execSessionManager) Tool {
if manager == nil {
manager = defaultExecSessionManager
}
description := "Runs a command in a PTY, returning output or a session ID for ongoing interaction."
if runtimeGOOS() == "windows" {
description += "\n\n" + shellGuidanceForGOOS(runtimeGOOS())
}
return execCommandTool{
baseTool: baseTool{
name: ExecCommandToolName,
description: description,
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"cmd": {Type: "string", Description: "Shell command to execute."},
"workdir": {Type: "string", Description: "Working directory for the command. Defaults to the turn cwd.", Default: "."},
"cwd": {Type: "string", Description: "Alias for workdir. Prefer workdir.", Default: "."},
"yield_time_ms": {Type: "integer", Description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.", Default: defaultExecYieldTimeMS, Minimum: intPtr(1), Maximum: intPtr(maxExecYieldTimeMS)},
"max_output_tokens": {Type: "integer", Description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.", Default: defaultMaxOutputTokens, Minimum: intPtr(1), Maximum: intPtr(maxExecOutputTokenRequest)},
"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\"]."},
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes.", Default: false},
},
Required: []string{"cmd"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
manager: manager,
}
}
func (tool execCommandTool) Run(ctx context.Context, args map[string]any) Result {
return tool.run(ctx, args, nil)
}
func (tool execCommandTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.run(ctx, args, engine)
}
func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
return tool.manager.list()
}
func (tool execCommandTool) StopExecSession(id int) bool {
return tool.manager.stop(id)
}
func (tool execCommandTool) StopAllExecSessions() []int {
return tool.manager.stopAll()
}
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
commandText, err := aliasedStringArg(args, []string{"cmd", "command", "script", "shell"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
workdir, err := aliasedStringArg(args, []string{"workdir", "cwd", "dir", "directory"}, ".", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultExecYieldTimeMS, 1, maxExecYieldTimeMS)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
maxOutputTokens, err := intArg(args, "max_output_tokens", defaultMaxOutputTokens, 1, maxExecOutputTokenRequest)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
ttyRequested, err := boolArg(args, "tty", false)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
sandboxPermissions, err := sandboxPermissionsArg(args)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + 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, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
return interactiveBlockResult(interactive)
}
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, workdir)
if err != nil {
return errorResult("Error running exec_command: " + err.Error())
}
session, err := tool.startSession(commandText, absoluteCwd, relativeCwd, ttyRequested, engine, sandboxPermissions)
if err != nil {
return errorResult("Error starting exec_command: " + err.Error())
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
if ctx != nil && ctx.Err() != nil && !session.doneClosed() {
session.terminate()
more, moreTruncated := session.collect(context.Background(), time.Second)
output += more
outputTruncated = outputTruncated || moreTruncated
}
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
func (tool execCommandTool) startSession(commandText string, absoluteCwd string, relativeCwd string, ttyRequested bool, engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) (*execSession, error) {
id := tool.manager.allocateID()
commandCtx, cancel := context.WithCancel(context.Background())
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
command, plan, err := buildBashCommand(commandCtx, commandText, absoluteCwd, commandEngine)
if err != nil {
cancel()
return nil, err
}
output := newExecOutputBuffer()
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
stdin, tty, cleanup, err := startExecProcess(command, output, ttyRequested)
if err != nil {
_ = monitor.Stop()
plan.Cleanup()
cancel()
return nil, err
}
session := &execSession{
id: id,
commandText: commandText,
cwd: absoluteCwd,
relativeCwd: relativeCwd,
startedAt: time.Now(),
lastUsedAt: time.Now(),
tty: tty,
command: command,
plan: plan,
cancel: cancel,
stdin: stdin,
cleanup: cleanup,
output: output,
done: make(chan struct{}),
}
tool.manager.store(session)
tool.manager.removeCompletedLater(session)
go func() {
err := command.Wait()
if blocks := monitor.Stop(); len(blocks) > 0 {
output.Write([]byte(appendSandboxBlocks("", blocks)))
}
if session.cleanup != nil {
session.cleanup()
}
plan.Cleanup()
cancel()
session.markDone(err, commandExitCode(err))
}()
return session, nil
}
// collect drains the session's output buffer, returning the accumulated text
// and whether the buffer ever had to drop output to stay within
// maxExecOutputBufferBytes since the last collect call.
func (session *execSession) collect(ctx context.Context, wait time.Duration) (string, bool) {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var builder strings.Builder
truncated := false
finish := func() (string, bool) {
if session.output.consumeTruncated() {
truncated = true
}
return builder.String(), truncated
}
for {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
if session.output.consumeTruncated() {
truncated = true
}
// A background process that keeps writing output faster than this
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
// a crash-restart spew) would otherwise never let drainString return
// empty, so the deadline check below — only reached on an empty
// drain — would never fire. That turns `wait` into an unbounded
// block regardless of what the caller asked for. Check it here too,
// so continuous output can never starve the timeout.
if time.Now().After(deadline) {
return finish()
}
continue
}
if session.doneClosed() {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
}
return finish()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
}
timer := time.NewTimer(remaining)
select {
case <-session.output.notify:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-session.done:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return finish()
case <-timer.C:
return finish()
}
}
}
type writeStdinTool struct {
baseTool
manager *execSessionManager
}
func NewWriteStdinTool(manager *execSessionManager) Tool {
if manager == nil {
manager = defaultExecSessionManager
}
return writeStdinTool{
baseTool: baseTool{
name: WriteStdinToolName,
description: "Writes characters to an existing unified exec session and returns recent output.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"session_id": {Type: "integer", Description: "Identifier of the running unified exec session."},
"chars": {Type: "string", Description: "Bytes to write to stdin. Defaults to empty, which polls without writing.", Default: ""},
"yield_time_ms": {Type: "integer", Description: "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.", Default: defaultPollYieldTimeMS, Minimum: intPtr(1), Maximum: intPtr(maxPollYieldTimeMS)},
"max_output_tokens": {Type: "integer", Description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.", Default: defaultMaxOutputTokens, Minimum: intPtr(1), Maximum: intPtr(maxExecOutputTokenRequest)},
},
Required: []string{"session_id"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
Reason: "Sending stdin can drive an existing shell process beyond the original command; empty polling and Ctrl-C interrupts are allowed automatically.",
AdvertiseInAuto: true,
},
},
manager: manager,
}
}
func (tool writeStdinTool) PermissionForArgs(args map[string]any) Permission {
raw, ok := args["chars"]
if !ok || raw == nil {
return PermissionAllow
}
chars, ok := raw.(string)
if !ok {
return PermissionPrompt
}
if chars == "" || chars == "\x03" {
return PermissionAllow
}
return PermissionPrompt
}
func (tool writeStdinTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]any, _ RunOptions) Result {
if value, ok := args["session_id"]; !ok || value == nil {
return errorResult("Error: Invalid arguments for write_stdin: session_id is required")
}
sessionID, err := intArg(args, "session_id", 0, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
chars, err := stringArgWithEmpty(args, "chars", "", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultPollYieldTimeMS, 1, maxPollYieldTimeMS)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
maxOutputTokens, err := intArg(args, "max_output_tokens", defaultMaxOutputTokens, 1, maxExecOutputTokenRequest)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
session, ok := tool.manager.get(sessionID)
if !ok {
return errorResult(fmt.Sprintf("Error: Unknown exec session_id %d.", sessionID))
}
session.touch()
interrupted := false
if chars != "" {
if shouldInterruptExecSession(chars, session.tty) {
interrupted = true
session.terminate()
} else if !session.tty {
return errorResult(fmt.Sprintf("Error: exec session_id %d does not accept stdin. Use empty chars to poll, or send chars \"\\u0003\" to interrupt/stop it.", sessionID))
} else if session.stdin != nil {
if _, err := io.WriteString(session.stdin, chars); err != nil && !session.doneClosed() {
return errorResult("Error writing to exec session: " + err.Error())
}
}
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: session.commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
func shouldInterruptExecSession(chars string, tty bool) bool {
if strings.Contains(chars, "\x03") {
return true
}
normalized := strings.ToLower(strings.TrimSpace(chars))
normalizedNoSpace := strings.ReplaceAll(normalized, " ", "")
switch normalizedNoSpace {
case `\u0003`, `\\u0003`, `\x03`, `\\x03`, "^c", "ctrl-c", "control-c", "sigint", "interrupt":
return true
}
if tty {
return false
}
switch normalized {
case "q", "quit", "exit", "stop", "kill", "terminate":
return true
}
return false
}
type execToolResultInput struct {
commandText string
output string
// outputBufferTruncated is true when the session's undrained output buffer
// had to drop bytes to stay within maxExecOutputBufferBytes since it was
// last collected — data that is gone for good, unlike the head/tail
// truncation below (which the caller can recover by polling again or
// raising max_output_tokens).
outputBufferTruncated bool
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
}
func execToolResult(input execToolResultInput) Result {
output, truncated := truncateExecOutput(input.output, input.maxOutputTokens)
meta := map[string]string{
"cwd": input.relativeCwd,
"tty": strconv.FormatBool(input.tty),
}
addSandboxMeta(meta, input.plan)
if input.exited {
meta["exit_code"] = strconv.Itoa(input.exitCode)
if input.interrupted {
meta["interrupted"] = "true"
}
if !input.interrupted {
markLikelySandboxDenial(meta, input.plan, input.exitCode, output)
}
} else {
meta["session_id"] = strconv.Itoa(input.sessionID)
}
if input.outputBufferTruncated {
meta["output_buffer_truncated"] = "true"
}
status := StatusOK
if input.exited && ((input.exitCode != 0 && !input.interrupted) || meta[SandboxLikelyDeniedMeta] == "true") {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
}
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
// text can land in the discarded middle or get chopped at a small
// max_output_tokens budget. Appending here guarantees it survives.
body += "\n" + execOutputBufferTruncatedMessage
}
return Result{
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Meta: meta,
Display: Display{
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
Kind: "shell",
},
}
}
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
output = strings.TrimRight(output, "\r\n")
parts := []string{}
if output != "" {
parts = append(parts, "output:\n"+output)
}
if exited {
if output == "" {
if interrupted {
parts = append(parts, "Command interrupted.")
} else {
parts = append(parts, "Command completed with no output.")
}
}
if interrupted {
parts = append(parts, "interrupted: true")
}
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
} else {
if output == "" {
parts = append(parts, "Command is still running.")
}
parts = append(parts, fmt.Sprintf("session_id: %d", sessionID))
parts = append(parts, fmt.Sprintf("Use write_stdin with session_id %d and empty chars to poll; send chars \"\\u0003\" to interrupt/stop it.", sessionID))
}
return strings.Join(parts, "\n")
}
func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
return truncateExecOutputSpill(output, maxOutputTokens, "exec_command")
}
// truncateExecOutputSpill keeps a head/tail window of the output within the
// token budget and, on truncation, spills the full output to disk so the model
// can grep/read the elided middle instead of re-running the command with a
// bigger budget. The spill is best-effort: when it fails the notice simply
// omits the file hint.
func truncateExecOutputSpill(output string, maxOutputTokens int, toolName string) (string, bool) {
if maxOutputTokens <= 0 {
maxOutputTokens = defaultMaxOutputTokens
}
maxBytes := maxOutputTokens * 4
if len(output) <= maxBytes {
return output, false
}
notice := "\n[zero] output truncated\n"
if spillPath := spillTruncatedOutput(toolName, output); spillPath != "" {
notice = "\n[zero] output truncated — full output saved to " + spillPath + " (grep or read_file it instead of re-running)\n"
}
head := maxBytes / 2
tail := maxBytes - head
return utf8Prefix(output, head) + notice + utf8Suffix(output, tail), true
}
func utf8Prefix(value string, maxBytes int) string {
if maxBytes <= 0 {
return ""
}
if len(value) <= maxBytes {
return value
}
for maxBytes > 0 && !utf8.RuneStart(value[maxBytes]) {
maxBytes--
}
return value[:maxBytes]
}
func utf8Suffix(value string, maxBytes int) string {
if maxBytes <= 0 {
return ""
}
if len(value) <= maxBytes {
return value
}
start := len(value) - maxBytes
for start < len(value) && !utf8.RuneStart(value[start]) {
start++
}
return value[start:]
}
func execDisplaySummary(commandText string, sessionID int, exited bool, exitCode int) string {
commandText = strings.TrimSpace(commandText)
if commandText == "" {
commandText = "command"
}
if exited {
return fmt.Sprintf("%s exited with code %d", commandText, exitCode)
}
return fmt.Sprintf("%s still running as session %d", commandText, sessionID)
}
func runtimeGOOS() string {
return runtime.GOOS
}