@sema-agent/core
Version:
Stateless, task-oriented AI agent core
901 lines • 53 kB
JavaScript
import { randomBytes } from "node:crypto";
import { Type } from "typebox";
import { defineTool, errorResult } from "../../core/tools.js";
import { isTimeCutCode } from "../../internal/harness.js";
import { defaultTaskRegistry } from "../../core/task-registry.js";
import { accountDroppedBytes, spoolDropNote } from "../../core/task-registry-shared.js";
import { RollingTailBuffer } from "../../core/exec-output-tail.js";
import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_MISSING_ID_MESSAGE, TASK_STOP_MISSING_ID_MESSAGE, TASK_STOP_PARAMS, resolveTaskIdArg, ENV_DIRECT_TASK_TOOL_CAPS, composeTaskOutputDescription, composeTaskOutputParams, composeTaskStopDescription, } from "../../core/task-tool-shape.js";
import { envDeclaresRetain, hasBackgroundShell, retainsBackgroundProcesses } from "../../core/background-shell.js";
import { delimitUntrusted } from "../../core/untrusted-text.js";
import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
import { withinAnyRoot } from "./safety.js";
import { imageMagicMatches } from "../../core/image-admission.js";
import { isDeadCwd, diagnoseDeadCwd } from "./dead-cwd.js";
import { boundaryIsTheSandbox, hasDestroy, isIsolated } from "../../core/remote-env.js";
import { ghRateLimitHint } from "./gh-rate-limit.js";
import { backgroundLifetimeNote } from "./fs-bash-background-text.js";
import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, shellRecoveryHint, CWD_SENTINEL, stripCwdSentinel, } from "./fs-shared.js";
import { bashClassifyDefaultAllow, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed } from "./bash-readonly-classifier.js";
import { toolFace } from "../../core/tool-catalog-entries.js";
import { thrownText } from "../../core/thrown-value.js";
function shellCommandOf(args) {
const command = args?.command;
return typeof command === "string" ? command : undefined;
}
function isBackgroundShellCall(args) {
return args?.run_in_background === true;
}
export function bashReversibilityProbe(allow, boundary) {
const allowSet = new Set(allow ?? bashClassifyDefaultAllow());
return (args) => {
const command = shellCommandOf(args);
if (command === undefined)
return { reversible: false };
if (isBackgroundShellCall(args))
return { reversible: false };
const home = typeof boundary === "function" ? boundary() : boundary;
const fold = home?.homeDir !== undefined ? { homeDir: home.homeDir } : undefined;
if (classifyCompoundReadonlyDetailed(command, allowSet, fold).reason === undefined)
return { reversible: true };
if (classifyBoundedReadonlyPollLoopDetailed(command, allowSet, fold).reason === undefined)
return { reversible: true };
return { reversible: false };
};
}
export { FULL_SHELL_CONTRACT_ID } from "../../core/tool-catalog-entries.js";
const EXIT1_INTERPRETATION = {
grep: "No matches found",
rg: "No matches found",
egrep: "No matches found",
fgrep: "No matches found",
find: "Some directories were inaccessible",
diff: "Files differ",
test: "Condition is false",
"[": "Condition is false",
};
export function bashExitOneInterpretation(command) {
const stmts = command.split(/\n|;|&&|\|\|/);
let lastStmt = "";
for (let i = stmts.length - 1; i >= 0; i--) {
if (stmts[i].trim().length > 0) {
lastStmt = stmts[i];
break;
}
}
const lastSeg = lastStmt.split("|").at(-1)?.trim() ?? "";
const tokens = lastSeg.split(/\s+/).filter(Boolean);
let i = 0;
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
i++;
const name = tokens[i]?.split("/").pop() ?? "";
if (name === "git") {
for (let j = i + 1; j < tokens.length; j++) {
const t = tokens[j];
if (t.startsWith("-")) {
if (t === "-C" || t === "-c")
j++;
continue;
}
if (t === "grep")
return "No matches found";
if (t === "diff")
return "Files differ";
return undefined;
}
return undefined;
}
return EXIT1_INTERPRETATION[name];
}
function dataUriImageFromStdout(stdout) {
const t = stdout.trim();
const m = /^data:image\/[a-z0-9.+_-]+;base64,([A-Za-z0-9+/=\s]+)$/i.exec(t);
if (!m)
return undefined;
const data = m[1].replace(/\s/g, "");
if (data.length === 0 || data.length > MCP_IMAGE_MAX_BASE64)
return undefined;
const bytes = Buffer.from(data, "base64");
for (const mime of ["image/png", "image/jpeg", "image/gif", "image/webp"]) {
if (imageMagicMatches(bytes, mime))
return { data, mime };
}
return undefined;
}
const AUTO_BACKGROUND_BLOCKED_FIRST_WORDS = new Set(["sleep"]);
function stripAutoBackgroundWrapperPrefixes(segment) {
let s = segment.trim();
const wrappers = [
/^timeout\s+(?:-[A-Za-z]\S*\s+)*\d+(?:\.\d+)?[smhd]?\s+/,
/^time\s+/,
/^nice(?:\s+-n?\s*-?\d+)?\s+/,
/^nohup\s+/,
/^command\s+/,
/^builtin\s+/,
/^stdbuf(?:\s+-[ioe]\S+)+\s+/,
/^noglob\s+/,
/^[A-Za-z_][A-Za-z0-9_]*=\S+\s+/,
];
let changed = true;
while (changed) {
changed = false;
for (const re of wrappers) {
const stripped = s.replace(re, "");
if (stripped !== s) {
s = stripped.trim();
changed = true;
}
}
}
return s;
}
function commandBasename(token) {
const idx = token.lastIndexOf("/");
return idx === -1 ? token : token.slice(idx + 1);
}
const COMMAND_STRING_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
function commandStringPayload(tokens) {
const word = commandBasename(tokens[0] ?? "");
if (word === "eval")
return tokens.slice(1).join(" ");
if (!COMMAND_STRING_SHELLS.has(word))
return undefined;
const flagIdx = tokens.findIndex((t, i) => i > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
return flagIdx === -1 ? undefined : tokens.slice(flagIdx + 1).join(" ");
}
function unquotePayload(payload) {
const s = payload.trim();
const ansiC = /^\$'([\s\S]*)'$/.exec(s);
if (ansiC)
return (ansiC[1] ?? "").replace(/\\n/g, "\n").replace(/\\t/g, "\t");
const quoted = /^(['"])([\s\S]*)\1$/.exec(s);
return quoted ? (quoted[2] ?? "") : s;
}
const AUTO_BACKGROUND_MAX_WRAPPER_DEPTH = 3;
function splitTopLevelStatements(command) {
const out = [];
let cur = "";
let quote;
for (let i = 0; i < command.length; i++) {
const c = command[i];
if (quote !== undefined) {
cur += c;
if (c === quote)
quote = undefined;
continue;
}
if (c === '"' || c === "'") {
quote = c;
cur += c;
continue;
}
if (c === ";" || c === "\n" || c === "|" || (c === "&" && command[i + 1] === "&")) {
if ((c === "&" || c === "|") && command[i + 1] === c)
i++;
out.push(cur);
cur = "";
continue;
}
if (c === "\r" && command[i + 1] === "\n") {
out.push(cur);
cur = "";
i++;
continue;
}
cur += c;
}
out.push(cur);
return out.map((s) => s.trim()).filter((s) => s.length > 0);
}
export function canAutoBackground(command, depth = 0) {
if (depth < AUTO_BACKGROUND_MAX_WRAPPER_DEPTH) {
for (const stmt of splitTopLevelStatements(command)) {
const tokens = stripAutoBackgroundWrapperPrefixes(stmt).split(/\s+/);
for (let i = 0; i < tokens.length; i++) {
const stmtPayload = commandStringPayload(tokens.slice(i));
if (stmtPayload !== undefined && stmtPayload.trim() !== "" && !canAutoBackground(unquotePayload(stmtPayload), depth + 1))
return false;
}
}
}
const segments = command
.split(/&&|\|\||;|\||\r?\n/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (segments.length === 0)
return true;
for (const seg of segments) {
const tokens = stripAutoBackgroundWrapperPrefixes(seg).split(/\s+/);
const word = commandBasename(tokens[0] ?? "");
if (word === "git")
return false;
const payload = depth < AUTO_BACKGROUND_MAX_WRAPPER_DEPTH ? commandStringPayload(tokens) : undefined;
if (payload !== undefined && payload.trim() !== "" && !canAutoBackground(unquotePayload(payload), depth + 1))
return false;
if (word === "xargs" && tokens.slice(1).some((t) => commandBasename(t) === "git"))
return false;
}
const firstWord = commandBasename(stripAutoBackgroundWrapperPrefixes(segments[0]).split(/\s+/)[0] ?? "");
if (AUTO_BACKGROUND_BLOCKED_FIRST_WORDS.has(firstWord))
return false;
return true;
}
function msTimeoutToRequestedSec(timeoutMs) {
if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
return undefined;
return Math.max(1, Math.round(timeoutMs / 1000));
}
const TOOL_PROGRESS_BOUND = Object.freeze({ intervalMs: 1_000, tailLines: 20, tailMaxBytes: 16 * 1024 });
function lastLines(text, n) {
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
const lines = body.split("\n");
return lines.length <= n ? body : lines.slice(-n).join("\n");
}
const EPILOGUE_HOLD_MAX_CHARS = 4096;
function createEpilogueFilter(marker) {
let held = "";
let inside = false;
return (chunk) => {
let buf = held + chunk;
held = "";
let out = "";
for (;;) {
if (inside) {
const close = buf.indexOf(marker);
if (close === -1)
break;
buf = buf.slice(close + marker.length);
inside = false;
continue;
}
const open = buf.indexOf(marker);
if (open === -1)
break;
out += buf.slice(0, open);
buf = buf.slice(open + marker.length);
inside = true;
}
if (inside) {
if (buf.length > EPILOGUE_HOLD_MAX_CHARS) {
inside = false;
return out + marker + buf;
}
held = buf;
return out;
}
for (let n = Math.min(marker.length - 1, buf.length); n > 0; n--) {
if (buf.endsWith(marker.slice(0, n))) {
held = buf.slice(buf.length - n);
return out + buf.slice(0, buf.length - n);
}
}
return out + buf;
};
}
function describeSafely(sink, body) {
if (sink === undefined)
return;
try {
const returned = sink(body);
if (typeof returned?.then === "function")
void returned.then(undefined, () => { });
}
catch {
}
}
function startLivenessTicks(onTick, filterStderr) {
const tail = new RollingTailBuffer(TOOL_PROGRESS_BOUND.tailMaxBytes);
let totalBytes = 0;
let totalLines = 0;
let cached;
const feed = (chunk) => {
if (chunk === "")
return;
const bytes = Buffer.from(chunk, "utf8");
totalBytes += bytes.length;
totalLines += chunk.split("\n").length - 1;
tail.push(bytes);
};
const describeTo = (t) => describeSafely(onTick, t);
const tick = () => {
if (totalBytes === 0) {
describeTo({});
return;
}
if (cached?.atBytes !== totalBytes)
cached = { atBytes: totalBytes, text: lastLines(tail.result().text, TOOL_PROGRESS_BOUND.tailLines) };
describeTo({ output: cached.text, totalLines, totalBytes });
};
const timer = setInterval(tick, TOOL_PROGRESS_BOUND.intervalMs);
timer.unref?.();
return {
onStdout: feed,
onStderr: filterStderr === undefined ? feed : (chunk) => feed(filterStderr(chunk)),
stop: () => clearInterval(timer),
};
}
async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, cwdRef, detach, readOnly, containmentRoots, onTick) {
const requestedSec = Math.max(1, Math.floor(timeoutSec ?? caps.defaultSec));
let timeout = Math.min(caps.maxSec, requestedSec);
const cappedByMaxTimeout = requestedSec > caps.maxSec;
const effectiveCwd = cwdRef ? cwdRef.current : cwd;
const cwdMarker = cwdRef ? `${CWD_SENTINEL}${randomBytes(8).toString("hex")}` : CWD_SENTINEL;
const toRun = cwdRef
? `${command}\n__cc_rc=$?\n{ set +vx; } 2>/dev/null\nprintf '%s%s%s' '${cwdMarker}' "$(pwd)" '${cwdMarker}' 1>&2\nexit $__cc_rc`
: command;
const execEnv = cwdRef ? { env: { PWD: effectiveCwd } } : undefined;
const live = onTick === undefined ? undefined : startLivenessTicks(onTick, cwdRef ? createEpilogueFilter(cwdMarker) : undefined);
let res;
try {
res = await env.exec(toRun, {
cwd: effectiveCwd,
timeout,
abortSignal: signal,
...execEnv,
...(live !== undefined ? { onStdout: live.onStdout, onStderr: live.onStderr } : {}),
...(detach
? {
detachSignal: detach.signal,
autoBackgroundOnTimeout: detach.autoBackgroundEligible === true,
}
: {}),
});
}
finally {
live?.stop();
}
if (!res.ok) {
if (res.error.code === "spawn_error" || res.error.code === "shell_unavailable") {
const diagnosis = await diagnoseDeadCwd(env, toolName, effectiveCwd, cwd, cwdRef, signal);
throw new Error(diagnosis ?? `Error (${toolName}): command did not run (${res.error.code}): ${res.error.message}`);
}
if (res.error.code === "transport_lost") {
return {
content: `Error (${toolName}): connection to the execution environment was lost — the command's outcome is unknown and it may still be running. Verify its effects before retrying; retry only if the command is idempotent. (${res.error.message})`,
details: { type: "bash", transportLost: true },
isError: true,
};
}
if (res.error.code === "suspended") {
return {
content: `Error (${toolName}): the execution environment is suspended (its workspace VM is paused), so the command did NOT run and no command can run in this leg. Do not retry it here — the task must be resumed first; report that the workspace is suspended. (${res.error.message})`,
details: { type: "bash", envSuspended: true },
isError: true,
};
}
if (res.error.code === "auth_failed") {
return {
content: `Error (${toolName}): authentication to the execution environment was rejected, so the command did NOT run. Do NOT retry — this is a permanent credential failure that an operator must fix; report it instead of trying other commands. (${res.error.message})`,
details: { type: "bash", authFailed: true },
isError: true,
};
}
if (res.error.code === "target_unavailable") {
return {
content: `Error (${toolName.slice(0, 64)}): the execution target is unavailable (offline/busy/not bound), so the command did NOT start and no effects landed. Retrying the same command is safe once the target is reachable again. (${res.error.message.slice(0, 512)})`,
details: { type: "bash", targetUnavailable: true },
isError: true,
};
}
if (isTimeCutCode(res.error.code) || res.error.code === "aborted" || res.error.code === "callback_error") {
const rawStdout = res.error.partialStdout ?? "";
const rawStderr = res.error.partialStderr ?? "";
const cutOverflowFile = rawStdout.length > bashMaxOutputChars() || rawStderr.length > bashMaxOutputChars()
? await writeShellOverflowFile(env, rawStdout, rawStderr)
: undefined;
const pStdout = clipShellOutput(rawStdout);
const pStderr = clipShellOutput(rawStderr);
const noOutputSec = res.error.code === "liveness_no_output" && typeof res.error.noOutputSec === "number" && Number.isFinite(res.error.noOutputSec) && Math.floor(res.error.noOutputSec) >= 1
? Math.floor(res.error.noOutputSec)
: undefined;
const headline = res.error.code === "timeout"
? cappedByMaxTimeout
? `Command timed out after ${timeout}s — requested ${requestedSec}s, capped at ${caps.maxSec}s (engine ceiling: requests above ${caps.maxSec}s are reduced to it). The process was killed; output produced before the cutoff is shown below. Re-running with a larger timeout gets the same ${caps.maxSec}s ceiling — use run_in_background for work that needs longer, a narrower command, or resume from the partial progress below.`
: `Command timed out after ${timeout}s (timeout limit: ${timeout}s). The process was killed; output produced before the cutoff is shown below. Re-running the same command will likely time out again — consider run_in_background for long commands, a narrower command, or resuming from the partial progress below.`
: res.error.code === "liveness_no_output"
? `Command was killed after producing no output for ${noOutputSec !== undefined ? `${noOutputSec}s` : "the environment's no-output window"} (a command that writes nothing to stdout or stderr that long is treated as hung; this is not the timeout you requested). Output produced before the cutoff is shown below. Re-running the same command will likely stall the same way — make it print progress as it works, use run_in_background for long quiet commands, or split it into smaller steps.`
: res.error.code === "aborted"
? `Command was interrupted (aborted) before completion. Output produced before the interrupt is shown below.`
: `Command started but its completion could not be observed — a host-side output-stream callback failed mid-run and the process was terminated (${res.error.message}). Output captured before the cut is shown below; its effects up to that point may have landed. Verify before assuming it needs a full re-run.`;
const captured = (pStdout ? `--- partial stdout ---\n${pStdout}` : "") +
(pStderr ? `${pStdout ? "\n" : ""}--- partial stderr ---\n${pStderr}` : "");
const zeroOutput = captured.length === 0;
const zeroOutputHint = res.error.code === "liveness_no_output"
? ` — the process wrote nothing to its stdio for ${noOutputSec !== undefined ? `the whole ${noOutputSec}s window` : "the whole window"}; it may have been stalled before producing output, or holding it in a block buffer`
: res.error.code === "timeout" && timeout >= 60
? ` — the process ran the full ${timeout}s without writing to its stdio; it may have been stalled before producing output, or holding it in a block buffer`
: "";
const body = !zeroOutput
? `\n${delimitUntrusted("partial command output", captured)}`
: `\n(no output was produced before the cutoff${zeroOutputHint})`;
const overflowNote = cutOverflowFile !== undefined ? shellRecoveryHint(cutOverflowFile, readOnly) : "";
return {
content: `Error (${toolName}): ${headline}${body}${overflowNote}`,
details: {
type: "bash",
stdout: pStdout,
stderr: pStderr,
exitCode: null,
...(cutOverflowFile !== undefined ? { output_file: cutOverflowFile } : {}),
...(res.error.code === "timeout"
? {
timedOut: true,
timeoutSec: timeout,
...(cappedByMaxTimeout ? { requestedTimeoutSec: requestedSec, cappedToMaxTimeoutSec: caps.maxSec } : {}),
}
: res.error.code === "liveness_no_output"
? { livenessNoOutput: true, ...(noOutputSec !== undefined ? { noOutputSec } : {}) }
: res.error.code === "aborted"
? { aborted: true }
: { callbackError: true }),
},
...(zeroOutput ? { isError: true } : {}),
};
}
return {
content: `Error (${toolName}): command execution failed (${res.error.code}): ${res.error.message}. Whether the command started or completed could not be determined — verify its effects before retrying.`,
details: { type: "bash" },
isError: true,
};
}
if (res.value.detached && detach) {
return await detach.onDetached(res.value.detached.shellId, res.value.stdout, res.value.stderr, res.value.detached.cause);
}
let { stderr } = res.value;
const { stdout, exitCode } = res.value;
if (cwdRef) {
const epilogue = stripCwdSentinel(stderr, cwdMarker);
stderr = epilogue.stderr;
if (epilogue.cwd !== undefined) {
const captured = epilogue.cwd.trim();
const windowsWorld = /^[A-Za-z]:[\\/]/.test(cwdRef.current);
const msys = /^\/([A-Za-z])(\/.*)?$/.exec(captured);
if (/^[A-Za-z]:[\\/]/.test(captured))
cwdRef.current = captured;
else if (windowsWorld && msys)
cwdRef.current = `${msys[1]}:${msys[2] ?? "/"}`;
else if (captured.startsWith("/") && !windowsWorld)
cwdRef.current = captured;
}
}
let cwdOutsideNote = "";
if (cwdRef && containmentRoots !== undefined && containmentRoots.length > 0 && !withinAnyRoot(containmentRoots, cwdRef.current)) {
const cwdCanon = await env.canonicalPath(cwdRef.current, signal);
if (!(cwdCanon.ok && withinAnyRoot(containmentRoots, cwdCanon.value))) {
cwdOutsideNote = `NOTE: working directory is now outside the task root(s) (${cwdRef.current}); relative paths in the structured file tools (Read/Edit/Write/Grep/Glob) will resolve there and may be refused. Pass absolute in-root paths, or \`cd\` back inside.`;
}
}
const survivedCapNote = cappedByMaxTimeout
? `NOTE: requested timeout ${requestedSec}s was capped to the ${caps.maxSec}s engine ceiling (requests above the max are reduced to it) — no effect on this run (the command completed in time); use run_in_background for work that needs longer.`
: "";
const clippedStdout = clipShellOutput(stdout);
const clippedStderr = clipShellOutput(stderr);
const stdoutImage = dataUriImageFromStdout(stdout);
if (stdoutImage) {
const imgOverflowFile = stderr.length > bashMaxOutputChars() ? await writeShellOverflowFile(env, "", stderr) : undefined;
const imgOverflowNote = imgOverflowFile !== undefined ? shellRecoveryHint(imgOverflowFile, readOnly) : "";
return {
content: [
{ type: "image", data: stdoutImage.data, mimeType: stdoutImage.mime },
{ type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}${imgOverflowNote}${cwdOutsideNote ? `\n${cwdOutsideNote}` : ""}${survivedCapNote ? `\n${survivedCapNote}` : ""}` },
],
details: { type: "bash", stdout: "[image data]", stderr: clippedStderr, exitCode, isImage: true, ...(imgOverflowFile !== undefined ? { output_file: imgOverflowFile } : {}), ...(cappedByMaxTimeout ? { requestedTimeoutSec: requestedSec, cappedToMaxTimeoutSec: caps.maxSec } : {}) },
};
}
const cleanOverflowFile = stdout.length > bashMaxOutputChars() || stderr.length > bashMaxOutputChars() ? await writeShellOverflowFile(env, stdout, stderr) : undefined;
const overflowNote = cleanOverflowFile !== undefined ? shellRecoveryHint(cleanOverflowFile, readOnly) : "";
const exit1Note = exitCode === 1 ? bashExitOneInterpretation(command) : undefined;
const parts = [`exit code: ${exitCode}${exit1Note ? ` (${exit1Note} — exit 1 from this command is not an error)` : ""}`, `--- stdout ---\n${clippedStdout || "(empty)"}`];
if (stderr.trim() !== "")
parts.push(`--- stderr ---\n${clippedStderr}`);
if (overflowNote)
parts.push(overflowNote.trim());
if (cwdOutsideNote)
parts.push(cwdOutsideNote);
if (survivedCapNote)
parts.push(survivedCapNote);
return {
content: parts.join("\n"),
details: {
type: "bash",
stdout: clippedStdout,
stderr: clippedStderr,
exitCode,
...(exit1Note ? { returnCodeInterpretation: exit1Note } : {}),
...(cleanOverflowFile !== undefined ? { output_file: cleanOverflowFile } : {}),
...(cappedByMaxTimeout ? { requestedTimeoutSec: requestedSec, cappedToMaxTimeoutSec: caps.maxSec } : {}),
},
};
}
function bashDescription(coAuthor, caps, bgNotifies = false, bgRetained = false, bgSessionScoped = false, bgEnvIsolatedOwned = false) {
const coAuthorLines = coAuthor === false
? "- Follow the deployment's commit-message conventions."
: `- End git commit messages with:\nCo-Authored-By: ${coAuthor}`;
return `Executes a bash command and returns its output.
- Working directory persists between calls, but prefer absolute paths — a \`cd\` carries over and moves the base for every later command. Shell state (env vars, functions) does not persist; the shell is re-initialized each call, starting at the configured root.
- IMPORTANT: Avoid using this tool to run \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user.
- File search by name: use the Glob tool (NOT \`find\` or \`ls\`); content search: use the Grep tool (NOT \`grep\`/\`rg\` in the shell).
- \`timeout\` is in milliseconds: default ${caps.defaultMs}, max ${caps.maxMs}.
- \`run_in_background\` runs the command detached: it keeps running across turns and ${bgNotifies ? "re-invokes you when it exits" : "you read its output later with TaskOutput(task_id)"}. No \`&\` needed.${backgroundLifetimeNote(bgRetained, bgSessionScoped, bgEnvIsolatedOwned, "description")}
# Git
- Interactive flags (\`-i\`, e.g. \`git rebase -i\`, \`git add -i\`) are not supported in this environment.
- Use the \`gh\` CLI for GitHub operations (PRs, issues, API).
- Commit or push only when the user asks. If on the default branch, branch first.
${coAuthorLines}
# Notes
- The shell is NOT sandboxed by this tool: a command can read or write any reachable path and use the network. Every call is subject to the deployment's approval policy (it may be denied or require confirmation); a denied call should not be retried verbatim.
- Treat evidence of a deployment restriction — "Operation not permitted", "Read-only file system", a denied path, or a network failure — as a boundary, not a bug: adjust within the allowed scope or report the limit; don't reach for a destructive or privilege-escalating workaround.`;
}
async function adoptBackgroundShell(req) {
const { env, shellId, registry, description, toolUseId, ctxTaskId, ctxPrincipal, wiring } = req;
const sessionScoped = wiring.sessionId !== undefined;
const owner = sessionScoped ? wiring.sessionId : (ctxTaskId ?? wiring.taskOwner);
const scope = ctxPrincipal ?? wiring.taskScope;
const onNotify = wiring.onNotify;
let outputFile;
if (onNotify !== undefined) {
const tf = await env.createTempFile({ prefix: "bash-task-", suffix: ".output" });
if (tf.ok) {
const tfCanon = await env.canonicalPath(tf.value);
outputFile = tfCanon.ok ? tfCanon.value : tf.value;
}
}
try {
const taskId = registry.registerBackgroundBash({
env,
shellId,
description,
toolUseId,
...(owner !== undefined ? { owner } : {}),
...(scope !== undefined ? { scope } : {}),
...(sessionScoped ? { sessionScoped: true } : {}),
...(outputFile !== undefined ? { outputFile } : {}),
...(onNotify !== undefined ? { onTerminal: (n) => onNotify(n, { priority: "next" }) } : {}),
...(wiring.retainBackgroundProcesses !== undefined ? { retainBackgroundProcesses: wiring.retainBackgroundProcesses } : {}),
});
return { ok: true, taskId, outputFile };
}
catch (e) {
const reason = thrownText(e);
let kill;
try {
kill = await env.killBackground(shellId);
}
catch {
kill = undefined;
}
if (kill !== undefined && (kill.ok || kill.error.code === "not_found"))
return { ok: false, reason, killed: true };
return { ok: false, reason, killed: false, killMsg: kill !== undefined && !kill.ok ? kill.error.message : "killBackground threw" };
}
}
export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = { current: rootCanonical }, taskOpts = {}) {
const timeoutCaps = resolveBashTimeoutCaps(taskOpts);
const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
const bgNotifies = taskOpts.taskRegistry !== undefined && taskOpts.onTaskNotification !== undefined;
const bgRetained = hasBackgroundShell(env) && retainsBackgroundProcesses({ env: envDeclaresRetain(env), task: taskOpts.retainBackgroundProcesses === true });
const bgSessionScoped = taskOpts.sessionId !== undefined && taskOpts.taskRegistry !== undefined;
const bgEnvIsolatedOwned = hasDestroy(env) && isIsolated(env);
return defineTool({
name: "Bash",
...toolFace("bash"),
description: bashDescription(coAuthor, timeoutCaps, bgNotifies, bgRetained, bgSessionScoped, bgEnvIsolatedOwned),
parameters: Type.Object({
command: Type.String({ description: "The command to execute" }),
timeout: Type.Optional(Type.Number({ description: bashTimeoutParamDescription(timeoutCaps, false) })),
description: Type.Optional(Type.String({
description: 'Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.\n' +
"\n" +
"For simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):\n" +
'- ls → "List files in current directory"\n' +
'- git status → "Show working tree status"\n' +
'- npm install → "Install package dependencies"\n' +
"\n" +
"For commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:\n" +
'- find . -name "*.tmp" -exec rm {} \\; → "Find and delete all .tmp files recursively"\n' +
'- git reset --hard origin/main → "Discard all local changes and match remote main"\n' +
"- curl -s url | jq '.data[]' → \"Fetch JSON from URL and extract data array elements\"",
})),
run_in_background: Type.Optional(Type.Boolean({
description: "Set to true to run this command in the background.",
})),
}),
isConcurrencySafe: (args) => {
const command = shellCommandOf(args);
if (command === undefined || isBackgroundShellCall(args))
return false;
return coarseReadonlyCheck(command, new Set(BASH_READONLY_DEFAULT_ALLOW)) === undefined;
},
execute: async (args, ctx) => {
const { command, timeout, run_in_background, description } = args;
const timeoutRefusal = bashTimeoutArgRefusal(timeout, timeoutCaps);
if (timeoutRefusal !== undefined)
return errorResult(timeoutRefusal, { type: "bash_invalid_timeout", code: "bash_invalid_timeout" });
const refused = await taskOpts.shellReadAdmission?.("Bash", ctx.toolCallId, command, ctx.signal);
if (refused !== undefined)
return refused;
if (run_in_background) {
if (!hasBackgroundShell(env)) {
return errorResult("Error (Bash): this environment does not support background processes (run_in_background).");
}
if (cwdRef && (await isDeadCwd(env, cwdRef.current, ctx.signal))) {
const diagnosis = await diagnoseDeadCwd(env, "Bash", cwdRef.current, rootCanonical, cwdRef, ctx.signal);
if (diagnosis !== undefined)
throw new Error(diagnosis);
}
if (ctx.signal?.aborted === true)
return errorResult("Error (Bash): the call was cancelled before the background command started.");
const requestedTimeoutSec = timeout !== undefined ? Math.max(1, Math.round(timeout / 1000)) : undefined;
const r = await env.spawnBackground(command, {
cwd: cwdRef.current,
...(requestedTimeoutSec !== undefined ? { timeout: requestedTimeoutSec } : {}),
});
if (!r.ok)
return errorResult(`Error (Bash): the background process could not be started (${r.error.code}): ${envErrorDetail(r.error.message)}`);
const bgCaps = env.backgroundCapabilities;
const appliedTimeoutSec = typeof bgCaps.defaultBgTimeoutSec === "number" && typeof bgCaps.maxBgTimeoutSec === "number"
? Math.min(requestedTimeoutSec ?? bgCaps.defaultBgTimeoutSec, bgCaps.maxBgTimeoutSec)
: undefined;
const bgCappedByMax = appliedTimeoutSec !== undefined && requestedTimeoutSec !== undefined && typeof bgCaps.maxBgTimeoutSec === "number" && requestedTimeoutSec > bgCaps.maxBgTimeoutSec;
const budgetNote = appliedTimeoutSec !== undefined
? bgCappedByMax
? ` Time budget: requested ${requestedTimeoutSec}s, capped at ${bgCaps.maxBgTimeoutSec}s (env ceiling: requests above ${bgCaps.maxBgTimeoutSec}s are reduced to it) — the process may run up to ${appliedTimeoutSec}s.`
: ` Time budget: the process may run up to ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).`
: "";
const lifetimeNote = backgroundLifetimeNote(bgRetained, bgSessionScoped, bgEnvIsolatedOwned);
const registry = taskOpts.taskRegistry;
if (registry !== undefined) {
const onNotify = taskOpts.onTaskNotification;
const adopted = await adoptBackgroundShell({
env,
shellId: r.value.shellId,
registry,
description: description ?? command,
toolUseId: ctx.toolCallId,
ctxTaskId: ctx.taskId,
ctxPrincipal: ctx.principal,
wiring: { sessionId: taskOpts.sessionId, taskOwner: taskOpts.taskOwner, taskScope: taskOpts.taskScope, onNotify, retainBackgroundProcesses: taskOpts.retainBackgroundProcesses },
});
if (!adopted.ok) {
if (adopted.killed) {
return errorResult(`Error (Bash): background process was started but could not be registered (${adopted.reason}); it has been terminated.`);
}
return {
content: `Error (Bash): background process was started but could not be registered (${adopted.reason}), and the cleanup kill failed (${adopted.killMsg}). ` +
`The process could NOT be terminated — it may still be running, but it has no task_id, so TaskOutput/TaskStop cannot reach it. ` +
`If it must be stopped, kill it manually (e.g. \`pkill -f\` on its command line).`,
details: { type: "bash", registerFailed: true, killFailed: true },
isError: true,
};
}
const { taskId, outputFile } = adopted;
const interimNote = outputFile !== undefined
? ` Output file: ${outputFile} (full output is appended there — Read it any time).`
: ` Use TaskOutput("${taskId}") to check interim output.`;
if (taskOpts.oneShot === true && onNotify !== undefined) {
return (`Command running in background; task_id=${taskId}.${interimNote} This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${taskId}", block: true }). If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget. TaskStop("${taskId}") to stop.` +
budgetNote +
lifetimeNote);
}
return onNotify !== undefined
? `Command running in background; task_id=${taskId}.${interimNote} You will be notified when it completes — do not poll; ` +
`end your turn if nothing else is pending. TaskStop("${taskId}") to stop.` +
budgetNote +
lifetimeNote
: `Started in background; task_id=${taskId}. ` +
`Use TaskOutput("${taskId}") to read output (poll until status is no longer "running"); ` +
`TaskStop("${taskId}") to stop. No '&' needed.` +
budgetNote +
lifetimeNote;
}
return (`Started in background; task_id=${r.value.shellId}. ` +
`Use TaskOutput("${r.value.shellId}") to read output (poll until status is no longer "running"); ` +
`TaskStop("${r.value.shellId}") to stop. No '&' needed.` +
budgetNote +
lifetimeNote);
}
const detachHub = taskOpts.detachHub;
const detachRegistry = taskOpts.taskRegistry;
const autoBackgroundArmed = taskOpts.autoBackgroundOnTimeout === true && canAutoBackground(command);
let backgroundedForQueuedMessage = false;
const wantsTicks = taskOpts.onToolProgress !== undefined || (autoBackgroundArmed && detachHub !== undefined && taskOpts.queuedHumanInput !== undefined);
const onTick = !wantsTicks
? undefined
: (t) => {
describeSafely(taskOpts.onToolProgress, { toolCallId: ctx.toolCallId, toolName: "Bash", ...t });
if (!autoBackgroundArmed || backgroundedForQueuedMessage || detachHub === undefined)
return;
if (ctx.signal?.aborted === true)
return;
if (taskOpts.queuedHumanInput?.() !== true)
return;
backgroundedForQueuedMessage = true;
detachHub.request(ctx.toolCallId);
};
const detachEnv = hasBackgroundShell(env) ? env : undefined;
const detachChain = detachHub !== undefined && detachRegistry !== undefined && detachEnv !== undefined && detachEnv.backgroundCapabilities.supportsDetach === true
? {
signal: detachHub.signalFor(ctx.toolCallId),
onDetached: async (shellId, stdoutSoFar, stderrSoFar, cause) => {
const onNotify = taskOpts.onTaskNotification;
const adopted = await adoptBackgroundShell({
env: detachEnv,
shellId: shellId,
registry: detachRegistry,
description: description ?? command,
toolUseId: ctx.toolCallId,
ctxTaskId: ctx.taskId,
ctxPrincipal: ctx.principal,
wiring: { sessionId: taskOpts.sessionId, taskOwner: taskOpts.taskOwner, taskScope: taskOpts.taskScope, onNotify, retainBackgroundProcesses: taskOpts.retainBackgroundProcesses },
});
if (!adopted.ok) {
const outTail = stdoutSoFar.slice(-1_000);
const errTail = stderrSoFar.slice(-1_000);
const tails = (outTail ? `\n--- output so far (tail) ---\n${outTail}` : "") +
(errTail ? `\n--- stderr so far (tail) ---\n${errTail}` : "");
if (adopted.killed) {
return {
content: `Error (Bash): command was detached to the background but could not be registered (${adopted.reason}); ` +
`it has been terminated (an unregistered background process would have no task_id and could not be read or stopped).` +
tails,
details: { type: "bash", detached: true, registerFailed: true },
isError: true,
};
}
return {
content: `Error (Bash): command was detached to the background but could not be registered (${adopted.reason}), and the cleanup kill failed (${adopted.killMsg}). ` +
`The process could NOT be terminated — it may still be running, but it has no task_id, so TaskOutput/TaskStop cannot reach it. ` +
`If it must be stopped, kill it manually (e.g. \`pkill -f\` on its command line).` +
tails,
details: { type: "bash", detached: true, registerFailed: true, killFailed: true },
isError: true,
};
}
const { taskId, outputFile } = adopted;
const tail = stdoutSoFar.slice(-1_000);
const adoptCaps = detachEnv.backgroundCapabilities;
const adoptBudgetNote = typeof adoptCaps.defaultBgTimeoutSec === "number" && typeof adoptCaps.maxBgTimeoutSec === "number"
? ` Time budget: the process may run up to ${Math.min(adoptCaps.defaultBgTimeoutSec, adoptCaps.maxBgTimeoutSec)}s (hard cap ${adoptCaps.maxBgTimeoutSec}s).`
: "";
return {
content: (cause === "timeout"
? `Command exceeded its timeout but is still running — moved to background instead of being killed (task_id=${taskId}). `
: backgroundedForQueuedMessage
? `Command is still running and was moved to the background so a queued message could be delivered without waiting for it (task_id=${taskId}). It was NOT stopped and nothing about it failed. `
: `Command moved to background; task_id=${taskId}. `) +
(outputFile !== undefined
? `Output file: ${outputFile} (full output is appended there — Read it any time). `
: `Use TaskOutput("${taskId}") to read its output. `) +
`TaskStop("${taskId}") to stop it. ` +
(taskOpts.oneShot === true && onNotify !== undefined
?
`This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${taskId}", block: true }).`
: onNotify !== undefined
? `You will be notified when it completes — do not poll.`
: `Poll TaskOutput until its status is no longer "running".`) +
adoptBudgetNote +
backgroundLifetimeNote(bgRetained, bgSessionScoped, bgEnvIsolatedOwned) +
(tail ? `\n--- output so far (tail) ---\n${tail}` : ""),
details: { type: "bash", detached: true, task_id: taskId, ...(description !== undefined ? { description } : {}), ...(outputFile !== undefined ? { output_file: outputFile } : {}), ...(cause === "timeout" || backgroundedForQueuedMessage ? { autoBackgrounded: true } : {}) },
};
},
autoBackgroundEligible: taskOpts.autoBackgroundOnTimeout === true && canAutoBackground(command),
}
: undefined;
let res;
try {
res = await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, cwdRef, detachChain, false, [rootCanonical, ...(taskOpts.additionalRoots ?? [])], onTick);
}
finally {
taskOpts.detachHub?.gc(ctx.toolCallId);
}
const detached = typeof res !== "string" && res.details.detached === true;
const resText = typeof res === "string" ? res : typeof res.content === "string" ? res.content : undefined;
const hint = detached || resText === undefined ? undefined : ghRateLimitHint(command, resText, undefined, taskOpts.monitorToolActive, taskOpts.reminderMark);
const withHint = hint === undefined ? res : typeof res === "string" ? `${res}\n\n${hint}` : { ...res, content: `${resText}\n\n${hint}` };
if (typeof withHint === "string" || description === undefined)
return withHint;
if (withHint.details.detached)
return withHint;
return { ...withHint, details: { ...withHint.details, description } };
},
});
}
export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
const timeoutCaps = resolveBashTimeoutCaps(opts);
const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
const sample = [...allow].slice(0, 6).join(", ");
return defineTool({
name: "Bash",
...toolFace("bash-readonly"),
description: `Run a SINGLE read-only inspection command (e.g. ${sample}, …) and return its output. Shell ` +
"operators (pipes, redirects, ;, &&, command substitution, subshells) are rejected and only " +
"allowlisted commands run. Reads are confined to the workspace root(s): a path outside them is " +
"refused, as is one that cannot be resolved statically (e.g. a `~` path). " +
(boundaryIsTheSandbox(env) ? "" : "A path that leads outside via a symlink counts as outside, including one a glob pattern expands to or a recursive read traverses. ") +
"Still subject to the deployment's approval policy.",
parameters: Type.Object({
command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
timeout: Type.Optional(Type.Number({ description: bashTimeoutParamDescription(timeoutCaps, true) })),
}),
execute: async (args, ctx) => {
const { command, timeout } = args;
const timeoutRefusal = bashTimeoutArgRefusal(timeout, timeoutCaps);
if (timeoutRefusal !== undefined)
return errorResult(timeoutRefusal, { type: "bash_invalid_timeout", code: "bash_invalid_timeout" });
const reason = coarseReadonlyCheck(command, allow);
if (reason)
return errorResult(`Error (Bash): ${reason}`);
const refused = await opts?.shellReadAdmission?.("Bash", ctx.toolCallId, command, ctx.signal);
if (refused !== undefined)
return refused;
const readOnlyTick = opts?.onToolProgress === undefined ? undefined : (t) => describeSafely(opts.onToolProgress, { toolCallId: ctx.toolCallId, toolName: "Bash", ...t });
return await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, true, undefined, readOnlyTick);
},
});
}
export function createEnvTaskOutputTool(env) {
return defineTool({
name: TASK_OUTPUT_TOOL_NAME,
...toolFace("task-output-hands"),
description: composeTaskOutputDescription(ENV_DIRECT_TASK_TOOL_CAPS),
parameters: composeTaskOutputParams(ENV_DIRECT_TASK_TOOL_CAPS),
execute: async (args) => {
const { task_id, filter } = args;
const id = resolveTaskIdArg(task_id);
if (!id)
return errorResult(TASK_OUTPUT_MISSING_ID_MESSAGE);
if (!hasBackgroundShell(env)) {
return errorResult("Error (TaskOutput): this environment does not support background processes.");
}
let rx;
if (filter !== undefined) {
try {
rx = new RegExp(filter);
}
catch {
return errorResult(`Error (TaskOutput): invalid filter regex: ${filter}`);
}
}
const r = await env.pollBackground(id);
if (!r.ok)
return errorResult(`Error (TaskOutput): could not read ${id} (${r.error.code}): ${envErrorDetail(r.error.message)}`);
const shape = (text) => clipShellOutput(rx ? text.split(/\r?\n/).filter((l) => rx.test(l)).join("\n") : text);
const status = r.value.status === "exited" ? `exited(code ${r.value.exitCode})` : r.value.status;
const dropAcct = {};
accountDroppedBytes(dropAcct, r.value);
const droppedNote = spoolDropNote(dropAcct);
const body = `status: ${status}\n${droppedNote}--- new stdout ---\n${shape(r.value.stdout) || "(none)"}\n` +
`--- new stderr ---\n${shape(r.value.stderr) || "(none)"}`;
return delimitUntrusted(`TaskOutput ${id}`, body);
},
});
}
export function createEnvTaskStopTool(env, registry = defaultTaskRegistry) {
return defineTool({
name: TASK_STOP_TOOL_NAME,
...toolFace("task-stop-hands"),
description: composeTaskStopDescription(ENV_DIRECT_TASK_TOOL_CAPS),
parameters: TASK_STOP_PARAMS,
execute: async (args) => {
const { task_id, shell_id } = args;
const id = resolveTaskIdArg(task_id, shell_id);
if (!id)
return errorResult(TASK_STOP_MISSING_ID_MESSAGE);
if (!hasBackgroundShell(env)) {
return errorResult("Error (TaskStop): this environment does not support background processes.");
}
registry.markStopSourceByShellId(id, "parent", env);
const r = await env.killBackground(id);
if (!r.ok) {
registry.clearPendingStopSourceByShellId(id, "parent", env);
return errorResult(`${id} is unknown or has already ended.`);
}
return `Terminated ${id}.`;
},
});
}