Guide contents

Lesson 57 of 73

The Shell Tool

A coding agent becomes useful when it can run commands. With a shell it can compile code, run the tests, read the error messages, change the code, and try again. This lesson builds that ability as a tool, which is a function that the model can ask the agent to call. The shell tool takes one argument, a command line such as javac Hello.java, and returns what the command printed together with its exit code.

Starting a command from Java looks like one line of code. In practice five problems show up quickly. A command can print so much that it freezes while Java waits. A command can run forever. A command can start other programs that keep running after the agent stops it. A command can read secrets such as API keys from the environment and print them into the model's context. A command can ask a question that nobody can answer, such as Overwrite? [y/N], and wait. Each example in this lesson solves one of these problems, and Example 14 combines the solutions into one class.

After this lesson you can run commands from Java without hangs, leaked processes, or leaked secrets. You can also turn what a command printed into text that a model can act on.

The picture below shows where the shell tool sits in the agent loop. The model never touches the operating system. It writes a tool call, and the tool does the work.

Text
model  --tool call: {"command": "javac Hello.java"}-->  agent loop
                                                           |
                                                           v
                                                       ShellTool
                                                           |  starts
                                                           v
                                                    bash -c "javac Hello.java"
                                                           |  prints, exits
                                                           v
model  <--text: "Exit code: 1 ... error ..."-------  ShellTool

Four words appear throughout the lesson, so here are their meanings. A process is a running program. Every process has three standard streams: standard input (stdin, where it reads), standard output (stdout, where it writes normal results), and standard error (stderr, where it writes error messages). An environment variable is a named piece of text, such as PATH or HOME, that a process receives from the process that started it. A shell is a program that reads a command line, understands its syntax (pipes, quotes, &&), and starts the programs it names. bash is the most common shell on Linux and macOS.

Topic Java or operating system feature Example
Starting a shell ProcessBuilder, bash -c, bash -lc 1
Output and exit code getInputStream, getErrorStream, waitFor 2
Full pipes reader threads, redirectErrorStream 3
Timeouts waitFor(timeout), destroy, destroyForcibly 4
Process trees ProcessHandle.descendants 5
Process groups setsid, kill -- -pgid 6
Truncation head and tail with a marker 7
Working directory an EXIT trap that records $PWD 8
Environment removing secrets, non-interactive variables 9
Input /dev/null as standard input, /dev/tty 10
Background jobs log files, status, stop 11
Streaming a line callback, ANSI and \r cleanup 12
The result text exit code, output, isError 13
Project 23 ShellTool in a scripted agent session 14

The tool plugs into the agent from The Agent Loop and a Tool Framework (Project 21). That lesson defines a Tool interface with four parts: a name, a description, a JSON Schema that describes the arguments, and a run method. run takes the parsed arguments as a Jackson JsonNode and returns text. When run throws an exception, the agent turns it into an error result, which is a ToolMessage with isError set. Example 14 restates a reduced version of these types so that the program stays complete.

The file tools from File Tools (Project 22) keep every path inside a workspace, the directory the agent is allowed to work in. The shell tool uses the same idea for its working directory.

Every example here starts real processes (bash, sleep, javac) on Linux or macOS, uses short timeouts, and stops every process it starts. Examples 6, 10, 11, 13, and 14 use the setsid command from util-linux. Linux distributions ship it and macOS does not, and the text says what to use instead on macOS.

Example 1: ProcessBuilder with bash -c and bash -lc

A model writes commands the way a person types them into a terminal, with pipes, &&, quotes, and wildcards (globs such as *.java). Java's ProcessBuilder understands none of that. It starts one program and gives it a list of arguments, with no interpretation of the text.

The fix is to start bash itself and pass the whole command line as a single argument after -c, which tells bash to run that string as a command. Bash then does the parsing. The program below runs the same command under bash -c and under bash -lc, sets a working directory and an environment variable, runs a pipe, and runs a command that does not exist. Read each output line as an answer to the question "how was the child started?"

Java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class FirstShellCall {
    static String run(List<String> shell, String command, Path cwd) throws Exception {
        List<String> argv = new ArrayList<>(shell);
        argv.add(command);                                // the whole command line is one argument
        ProcessBuilder pb = new ProcessBuilder(argv);
        pb.directory(cwd.toFile());                       // working directory of the child
        pb.environment().put("GREETING", "hello");        // added to a copy of the JVM's environment
        pb.redirectErrorStream(true);
        Process p = pb.start();
        String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        p.waitFor();
        return out.strip();
    }

    public static void main(String[] args) throws Exception {
        Path ws = Files.createTempDirectory("ws").resolve("project");
        Files.createDirectories(ws);
        String cmd = "echo \"cwd=$(basename \"$PWD\") GREETING=$GREETING login=$(shopt -q login_shell && echo yes || echo no) args=$#\"";
        System.out.println("bash -c : " + run(List.of("bash", "-c"), cmd, ws));
        System.out.println("bash -lc: " + run(List.of("bash", "-lc"), cmd, ws));
        System.out.println("pipes   : " + run(List.of("bash", "-c"), "printf 'b\\na\\nc\\n' | sort | head -n 2 | tr '\\n' ' '", ws));
        System.out.println("missing : " + run(List.of("bash", "-c"), "no-such-command-xyz", ws));
        Files.delete(ws);
        Files.delete(ws.getParent());
    }
}
Output
bash -c : cwd=project GREETING=hello login=no args=0
bash -lc: cwd=project GREETING=hello login=yes args=0
pipes   : a b
missing : bash: line 1: no-such-command-xyz: command not found

Each output line answers one question about how the child process was started.

  • cwd=project shows that pb.directory(...) set the child's working directory, the directory that relative paths are resolved against. The command prints only the last path component with basename, because the temporary directory's full name is random and would change the output on every run. Without directory(...), the child inherits the JVM's working directory, which for an agent is wherever the user launched it.
  • GREETING=hello shows that pb.environment() is a mutable copy of the JVM's environment. Changes to the copy affect only this child. System.getenv() in the JVM is unchanged.
  • login=no and login=yes show the difference between the two flags. -c runs the string in a non-interactive, non-login shell. -l (so -lc means -l plus -c) makes bash a login shell, the kind that starts when a person logs in. A login shell reads /etc/profile and then the first of ~/.bash_profile, ~/.bash_login, or ~/.profile before it runs the command.
  • args=0 shows that the command string is not an argument list. With bash -c 'cmd' a b, bash sets $0 to a and $1 to b. The shell tool passes nothing after the command, so $# (the number of arguments) is 0.
  • pipes : a b shows that bash, not Java, runs the pipeline printf | sort | head | tr. If the same text went to ProcessBuilder directly, it would try to start a program named printf with 'b\na\nc\n' and | as literal arguments.
  • missing : shows what the model sees after a typo. Bash prints command not found on standard error, and the line number refers to a line of the command string.

The choice between -c and -lc has consequences on both sides.

A login shell picks up the user's PATH additions from their profile. PATH is the environment variable that lists the directories bash searches when it looks for a program. Version managers such as SDKMAN!, nvm, or pyenv often add themselves to it in a profile file, so mvn, node, or a specific java resolve in a login shell the way they do in the user's own terminal. The cost is that every call runs the profile scripts. These can take from a few milliseconds to hundreds of milliseconds, can print text that ends up in the tool output, and can export variables again that the agent removed (Example 9).

A non-login shell starts fast and runs with exactly the environment the agent builds. The cost is that a tool installed only through a profile script is not on its PATH.

The rest of this lesson uses bash -c and a PATH taken from the JVM, and Example 9 returns to this trade-off. For comparison, Pi's bash tool (version 0.73.1, dist/core/tools/bash.js and dist/utils/shell.js) runs /bin/bash -c <command> with the process environment plus its own bin directory on PATH, and does not start a login shell.

Example 2: capturing output and the exit code

When a command finishes, the agent needs three things from it. The first is what it wrote to standard output. The second is what it wrote to standard error. The third is its exit code, an integer from 0 to 255 that the process returns when it ends. By convention, 0 means success and anything else means some kind of failure, and the meaning of each non-zero value depends on the program.

The program below reads all three. It first keeps the two streams separate, and then merges standard error into standard output. After that it runs commands whose exit codes have specific meanings, so you can see the values a shell tool meets every day.

Java
import java.nio.charset.StandardCharsets;
import java.util.List;

public class OutputAndExitCode {
    record Result(int exitCode, String stdout, String stderr) {}

    static Result run(String command, boolean merge) throws Exception {
        ProcessBuilder pb = new ProcessBuilder(List.of("bash", "-c", command));
        pb.redirectErrorStream(merge);
        Process p = pb.start();
        p.getOutputStream().close();                      // no input for the child
        String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
        int code = p.waitFor();
        return new Result(code, out, err);
    }

    static void show(String label, Result r) {
        System.out.printf("%s exit=%d stdout=%s stderr=%s%n", label, r.exitCode(),
                r.stdout().replace("\n", "\\n"), r.stderr().replace("\n", "\\n"));
    }

    public static void main(String[] args) throws Exception {
        String cmd = "echo one; echo two >&2; echo three; exit 3";
        show("separate:", run(cmd, false));
        show("merged:  ", run(cmd, true));
        show("true:    ", run("true", false));
        show("false:   ", run("false", false));
        show("grep:    ", run("printf 'a\\nb\\n' | grep -c z", false));
        show("missing: ", run("no-such-command-xyz", false));
        show("syntax:  ", run("if then", false));
        show("killed:  ", run("kill -TERM $$", false));
    }
}
Output
separate: exit=3 stdout=one\nthree\n stderr=two\n
merged:   exit=3 stdout=one\ntwo\nthree\n stderr=
true:     exit=0 stdout= stderr=
false:    exit=1 stdout= stderr=
grep:     exit=1 stdout=0\n stderr=
missing:  exit=127 stdout= stderr=bash: line 1: no-such-command-xyz: command not found\n
syntax:   exit=2 stdout= stderr=bash: -c: line 1: syntax error near unexpected token `then'\nbash: -c: line 1: `if then'\n
killed:   exit=143 stdout= stderr=

The first two lines run the same command, echo one; echo two >&2; echo three; exit 3. The part >&2 sends the output of echo two to standard error.

  • separate: puts one and three in stdout and two in stderr. Separate streams keep the source of each line but lose the order between them. Nothing in the two strings says that two was printed between one and three.
  • merged: uses redirectErrorStream(true), which makes the child's standard error the same pipe as its standard output. The order is kept (one, two, three) and the error stream is empty. For a model, the order usually matters more than the source, because a compiler error is easier to read next to the line that came before it. Most coding agents send merged output.
  • exit=3 in both lines is the value of exit 3. waitFor() returns it once the process ends.

The remaining lines are exit codes that a shell tool sees every day.

Line Exit code Meaning
true 0 success
false 1 generic failure
grep 1 grep -c printed 0: no line matched. For grep, 1 means "no match" and 2 means an error, so 1 is not a failure of the command
missing 127 bash could not find the command
syntax 2 bash could not parse the command line; the message quotes the bad text
killed 143 the process ended by signal 15 (SIGTERM); Java reports a death by signal N as 128 + N

A signal is a small message that the operating system delivers to a process, for example "please stop". Example 4 explains signals in detail. For now, the rule to remember is that a process killed by signal N reports the exit code 128 + N, so 15 gives 143.

The syntax line says bash: -c: line 1:, which tells the model that the error is in its own command string and not in a script file. The killed line uses kill -TERM $$, where $$ is the shell's own process id, so the shell kills itself. Example 4 shows the same code when Java sends the signal.

The program closes p.getOutputStream() right after start(). That stream is the child's standard input seen from Java, so closing it means the child gets no input (Example 10). The program also reads stdout completely before it reads stderr. That order is safe only because the outputs here are a few bytes. Example 3 shows what happens with large output.

Example 3: reading output while the process runs

Between Java and the child, each stream is a pipe, a buffer inside the operating system kernel that has a fixed capacity. One side writes into it and the other side reads from it. On Linux, a pipe holds 16 pages by default, which is 65,536 bytes with 4 KiB pages (pipe(7)).

When the buffer is full, the child's next write blocks, which means the child stops and waits until the reader takes some data out. Now suppose the Java side waits for the process to end before it reads. If the child writes more than the buffer holds, the child waits for Java to read, and Java waits for the child to end. Both wait forever. This is a deadlock.

Text
child writes ----> [ pipe: 65,536 bytes max ] ----> Java reads
                      full: child's write blocks
Java calls waitFor() and does not read   =>   nobody empties the pipe   =>   hang

The program below writes 200,000 bytes to stderr, which is more than three pipe buffers. It shows the hang and then two fixes. Pay attention to the first line of output, because the command needs only a few milliseconds of real work.

Java
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class PipeDeadlock {
    // 200,000 bytes to stderr, then one short line to stdout
    static final String CMD = "head -c 200000 /dev/zero | tr '\\0' 'x' >&2; echo done";

    static Thread drain(InputStream in, ByteArrayOutputStream sink) {
        return Thread.ofVirtual().start(() -> {
            try (in) { in.transferTo(sink); } catch (Exception e) { throw new RuntimeException(e); }
        });
    }

    public static void main(String[] args) throws Exception {
        // 1. Wait first, read later: the child blocks once the stderr pipe is full.
        Process p = new ProcessBuilder(List.of("bash", "-c", CMD)).start();
        boolean finished = p.waitFor(500, TimeUnit.MILLISECONDS);
        System.out.println("wait first: finished within 500 ms = " + finished);
        p.descendants().forEach(ProcessHandle::destroyForcibly);
        p.destroyForcibly().waitFor();

        // 2. Drain both pipes on their own threads while the child runs.
        Process q = new ProcessBuilder(List.of("bash", "-c", CMD)).start();
        ByteArrayOutputStream out = new ByteArrayOutputStream(), err = new ByteArrayOutputStream();
        Thread t1 = drain(q.getInputStream(), out), t2 = drain(q.getErrorStream(), err);
        boolean done = q.waitFor(10, TimeUnit.SECONDS);
        t1.join(); t2.join();
        System.out.println("drain both: finished = " + done + ", exit = " + q.exitValue()
                + ", stdout = " + out.size() + " bytes, stderr = " + err.size() + " bytes");

        // 3. Merge stderr into stdout and read one stream.
        Process r = new ProcessBuilder(List.of("bash", "-c", CMD)).redirectErrorStream(true).start();
        long merged = r.getInputStream().readAllBytes().length;
        System.out.println("merged:     exit = " + r.waitFor() + ", " + merged + " bytes on one stream");
    }
}
Output
wait first: finished within 500 ms = false
drain both: finished = true, exit = 0, stdout = 5 bytes, stderr = 200000 bytes
merged:     exit = 0, 200005 bytes on one stream

Here is what each line shows.

  • wait first: finished within 500 ms = false. The command needs a few milliseconds of work, but it did not finish in 500 ms. The tr process wrote the first 65,536 bytes into the stderr pipe and then blocked, because nobody reads that pipe. Bash in turn waits for tr. Without the timeout, waitFor() would never return. The program kills the children and the shell before it continues, so no process is left behind.
  • drain both: finished = true, exit = 0, stdout = 5 bytes, stderr = 200000 bytes. Two virtual threads (lightweight threads managed by the JVM, see Virtual Threads) copy each stream into a buffer while the main thread waits. The pipes never fill, so the process runs to the end. The 5 bytes of stdout are the word done plus a newline. The 200,000 bytes of stderr are the x characters.
  • merged: exit = 0, 200005 bytes on one stream. With redirectErrorStream(true) there is only one pipe. Reading it to the end on the main thread before waitFor() is enough. readAllBytes() returns when the child closes its end of the pipe, which happens when the child exits. The total of 200,005 is the 200,000 x bytes plus the 5 bytes of done and a newline.

The rule for the shell tool follows from these three lines: every pipe the child writes to must have a reader from the moment the process starts. Two streams need two readers, one per stream, each on its own thread. One merged stream needs one reader.

Another option is to send output to a file with redirectOutput(File). The kernel then writes the file, and no Java reader is needed. Example 11 uses that for background jobs. The same rule applies in the other direction to the child's standard input, which Example 10 covers.

Example 4: timeouts, destroy, and destroyForcibly

A command can run forever. A test can deadlock, a server can be started in the foreground, or tail -f can wait for new lines that never come. The shell tool therefore gives every command a timeout, a maximum running time after which the tool stops the process.

Process.waitFor(long, TimeUnit) waits up to the timeout and returns false if the process is still running. Stopping the process then has two levels. The first level is polite: ask the process to stop with a signal that it may handle. The second level is forceful: end the process with a signal that it cannot refuse.

A signal is a small message from the operating system to a process. SIGTERM (signal 15) means "please terminate". A program can catch it to clean up first, or ignore it. SIGKILL (signal 9) means "terminate now". The kernel enforces it, and the program never sees it. The program below shows both levels, including a command that ignores the polite one.

Java
import java.util.List;
import java.util.concurrent.TimeUnit;

public class Timeouts {
    static Process start(String command) throws Exception {
        return new ProcessBuilder(List.of("bash", "-c", command))
                .redirectErrorStream(true)
                .redirectOutput(ProcessBuilder.Redirect.DISCARD)
                .start();
    }

    static List<ProcessHandle> awaitChildren(Process p, int n) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            List<ProcessHandle> c = p.descendants().toList();
            if (c.size() >= n) return c;
            Thread.sleep(10);
        }
        throw new IllegalStateException("children did not start");
    }

    public static void main(String[] args) throws Exception {
        // 1. A command that finishes in time.
        Process quick = start("sleep 0.1");
        System.out.println("sleep 0.1: finished=" + quick.waitFor(2, TimeUnit.SECONDS) + " exit=" + quick.exitValue());

        // 2. A command that does not: destroy() sends SIGTERM.
        Process slow = start("sleep 30");
        boolean finished = slow.waitFor(300, TimeUnit.MILLISECONDS);
        slow.destroy();
        slow.waitFor();
        System.out.println("sleep 30: finished=" + finished + " after destroy() exit=" + slow.exitValue());

        // 3. A command that ignores SIGTERM: destroy() is not enough.
        Process stubborn = new ProcessBuilder(List.of("bash", "-c", "trap '' TERM; sleep 30; echo late")).start();
        List<ProcessHandle> children = awaitChildren(stubborn, 1);   // bash has set the trap and started sleep
        stubborn.destroy();
        boolean gone = stubborn.waitFor(300, TimeUnit.MILLISECONDS);
        System.out.println("trap '' TERM: gone after destroy()=" + gone);
        stubborn.destroyForcibly();                         // SIGKILL cannot be ignored
        stubborn.waitFor();
        System.out.println("after destroyForcibly() exit=" + stubborn.exitValue());
        System.out.println("child sleep still alive=" + children.stream().anyMatch(ProcessHandle::isAlive));
        children.forEach(ProcessHandle::destroyForcibly);   // clean up what example 5 explains
    }
}
Output
sleep 0.1: finished=true exit=0
sleep 30: finished=false after destroy() exit=143
trap '' TERM: gone after destroy()=false
after destroyForcibly() exit=137
child sleep still alive=true

Here is what each line shows.

  • sleep 0.1: finished=true exit=0. The command ends well inside its 2-second limit, so waitFor returns true and the exit code is 0.
  • sleep 30: finished=false after destroy() exit=143. After 300 ms the process is still running. destroy() asks it to stop. On Linux and macOS the JDK sends SIGTERM. The sleep program neither catches nor ignores it, so it dies from the signal, and Java reports 128 + 15 = 143. Bash runs a single simple command such as sleep 30 by replacing itself with that program (an exec), which is why the signal reached sleep directly.
  • trap '' TERM: gone after destroy()=false. trap '' TERM makes bash ignore SIGTERM, and a program started by bash inherits ignored signals, so sleep ignores it too. destroy() sends the signal, nothing happens, and the process is still alive 300 ms later. In this case bash does not replace itself with sleep, because it still has echo late to run after it.
  • after destroyForcibly() exit=137. destroyForcibly() sends SIGKILL, which a process cannot catch or ignore. Bash dies, and Java reports 128 + 9 = 137.
  • child sleep still alive=true. SIGKILL went to bash, the process Java started, and not to its child sleep. The child lost its parent and kept running, and now belongs to nobody. The program captured the child's handle before the kill so that it can stop the child in the last line. Example 5 turns this cleanup into a method.

The program waits for the child to appear with awaitChildren instead of sleeping for a fixed time. Once sleep exists, bash has already run trap, so the test does not depend on how busy the machine is.

The usual sequence in agents has four steps: wait for the timeout, send SIGTERM, wait a short grace period (a second or two), then send SIGKILL. The grace period lets a test runner write its report or a build tool remove its lock file. The sequence is still incomplete while it signals only one process, and the next two examples fix that.

Example 5: killing process trees with ProcessHandle.descendants

A command line often starts several processes. mvn test starts a JVM that starts more JVMs for the tests. npm run dev starts a shell that starts node. The starting process is the parent and each process it starts is a child. The set of a process, its children, their children, and so on is a process tree.

Text
bash (the process Java started)
 `-- subshell
      |-- sleep 30
      `-- sleep 30

ProcessHandle (Java 9 and later) represents any process by its id. It can list a process's children() (direct children only) and descendants() (all levels), and each handle can be signalled on its own. The program below builds a three-level tree like the one above. It kills only the root, looks at what survives, and then kills the whole tree.

Java
import java.util.List;

public class ProcessTree {
    static List<ProcessHandle> awaitDescendants(Process p, int n) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            List<ProcessHandle> d = p.descendants().toList();
            if (d.size() >= n) return d;
            Thread.sleep(10);
        }
        throw new IllegalStateException("descendants did not start");
    }

    static long alive(List<ProcessHandle> hs) { return hs.stream().filter(ProcessHandle::isAlive).count(); }

    // Snapshot the tree, kill every process in it, and wait until each one has exited.
    static void killTree(ProcessHandle root) {
        List<ProcessHandle> all = root.descendants().toList();
        root.destroyForcibly();
        all.forEach(ProcessHandle::destroyForcibly);
        root.onExit().join();
        all.forEach(h -> h.onExit().join());
    }

    public static void main(String[] args) throws Exception {
        // bash starts a subshell, which starts two sleeps; bash then waits.
        String cmd = "(sleep 30 & sleep 30 & wait) & wait";

        Process p1 = new ProcessBuilder(List.of("bash", "-c", cmd)).start();
        List<ProcessHandle> tree1 = awaitDescendants(p1, 3);
        System.out.println("tree: root bash, children=" + p1.children().count() + ", descendants=" + tree1.size());
        p1.destroyForcibly().waitFor();                        // kills bash only
        System.out.println("after killing the root only: root alive=" + p1.isAlive()
                + ", descendants alive=" + alive(tree1));
        tree1.forEach(ProcessHandle::destroyForcibly);         // clean up the orphans
        tree1.forEach(h -> h.onExit().join());

        Process p2 = new ProcessBuilder(List.of("bash", "-c", cmd)).start();
        List<ProcessHandle> tree2 = awaitDescendants(p2, 3);
        killTree(p2.toHandle());
        System.out.println("after killTree: root alive=" + p2.isAlive() + ", descendants alive=" + alive(tree2));
    }
}
Output
tree: root bash, children=1, descendants=3
after killing the root only: root alive=false, descendants alive=3
after killTree: root alive=false, descendants alive=0

The command (sleep 30 & sleep 30 & wait) & wait makes bash start a subshell in the background. A subshell is a child copy of the shell that runs the commands inside the parentheses. The subshell starts two sleep processes in the background and waits for both, and the outer bash waits for the subshell.

  • tree: root bash, children=1, descendants=3. Bash has one direct child, the subshell. descendants() walks the whole tree and finds three processes: the subshell and the two sleeps.
  • after killing the root only: root alive=false, descendants alive=3. destroyForcibly() on the Process kills bash and nothing else. All three descendants are still running. A process whose parent has died is an orphan. On Linux, the kernel gives an orphan a new parent, usually PID 1 or the nearest subreaper (a process that asked the kernel to adopt orphans below it). From then on, the orphans are no longer descendants of anything Java knows about, so a later descendants() call cannot find them. The program kept the list from before the kill and uses it to clean up.
  • after killTree: root alive=false, descendants alive=0. killTree takes the list first, kills the root and every listed descendant with SIGKILL, and then waits on onExit() for each one. The method returns only after all of them are gone.

killTree has a gap in time. A process can start a new child after the list is taken and before the kill happens. For a shell tool that kills a tree after a timeout, this window is small. A tool such as make -j, however, can start new processes at a high rate. A second gap is a process that leaves the tree on purpose. Example 6 closes both problems with a process group.

The loop serve supervisor in Workshop: loop serve, run, and chat uses the same descendants-then-root kill for the engine process.

Example 6: process groups for processes that leave the tree

Some programs daemonize. This means they start a child and let the parent exit at once, so the child is reparented and keeps running after the command that started it has returned. Shell code does the same with ( cmd & ). descendants() cannot see such a process, because its parent is no longer in the tree.

The operating system has a second way to relate processes. A process group is a set of processes that the kernel can signal together. A child joins its parent's group by default and stays in it after reparenting, so the group still contains the escaped process. The setsid command starts a program as the leader of a new session and a new process group, and the group's id equals the program's process id.

Text
without setsid:  escaped sleep belongs to the same group as the JVM's other children (hard to target)
with setsid:     group id = pid of bash;  bash, subshell, escaped sleep are all members
                 kill -KILL -- -<that pid>   signals every member

The program below compares the two approaches on a command that leaves one sleep behind.

Java
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;

public class ProcessGroup {
    // The subshell starts sleep in the background, prints its pid, and exits, so that sleep
    // loses its parent and the kernel reparents it. "next" is printed after the subshell is gone.
    static final String CMD = "( sleep 30 & echo $! ); echo next; sleep 30";

    static long escapedPid(Process p) throws Exception {
        BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
        long pid = Long.parseLong(r.readLine().trim());
        if (!"next".equals(r.readLine())) throw new IllegalStateException();
        return pid;
    }

    public static void main(String[] args) throws Exception {
        // 1. Without a group: descendants() does not see the escaped sleep.
        Process p = new ProcessBuilder(List.of("bash", "-c", CMD)).start();
        ProcessHandle escaped = ProcessHandle.of(escapedPid(p)).orElseThrow();
        System.out.println("escaped is a descendant: " + p.descendants().anyMatch(h -> h.pid() == escaped.pid()));
        p.descendants().forEach(ProcessHandle::destroyForcibly);
        p.destroyForcibly().waitFor();
        System.out.println("after killing the tree, escaped alive: " + escaped.isAlive());
        escaped.destroyForcibly();
        escaped.onExit().join();

        // 2. With setsid: bash leads a new session and process group whose id is its pid.
        Process q = new ProcessBuilder(List.of("setsid", "bash", "-c", CMD)).start();
        ProcessHandle escaped2 = ProcessHandle.of(escapedPid(q)).orElseThrow();
        System.out.println("setsid: escaped is a descendant: " + q.descendants().anyMatch(h -> h.pid() == escaped2.pid()));
        Process kill = new ProcessBuilder("kill", "-KILL", "--", "-" + q.pid()).start();   // negative pid = whole group
        System.out.println("kill -KILL -- -<pgid> exit=" + kill.waitFor());
        q.waitFor();
        escaped2.onExit().join();
        System.out.println("after killing the group, escaped alive: " + escaped2.isAlive());
    }
}
Output
escaped is a descendant: false
after killing the tree, escaped alive: true
setsid: escaped is a descendant: false
kill -KILL -- -<pgid> exit=0
after killing the group, escaped alive: false

The command prints the pid of the escaped sleep, prints next once the subshell has exited, and then runs a second sleep in the foreground. The program reads the line next before it checks anything, which guarantees that the reparenting has already happened.

  • escaped is a descendant: false. The subshell exited, so the escaped sleep is not below bash any more.
  • after killing the tree, escaped alive: true. Killing every descendant plus the root (the approach of Example 5) leaves the escaped process running. A test server started this way would hold its port open after the agent moved on.
  • setsid: escaped is a descendant: false. This is the same command under setsid. The escaped process is still outside the tree, but it is in the group that setsid created.
  • kill -KILL -- -<pgid> exit=0. The kill command with a negative number signals every process in the group with that id. Java's ProcessHandle has no method to signal a group, so the program runs the kill program for it. The -- ends kill's option parsing, so that the negative number is read as a pid and not as an option.
  • after killing the group, escaped alive: false. The group kill reached the escaped process.

setsid creates the new session in the same process when the caller is not already a process group leader, and forks first when it is. A child started by Java is not a group leader, so q.pid() is the pid of bash itself and the group id equals it.

Two limits remain. A process that calls setsid() itself leaves the group. And macOS has no setsid command; a small wrapper that calls the setsid() system call, or a container, fills the gap there.

Pi spawns its shell as a detached process, which on Unix makes it a group leader. On timeout or abort it sends SIGKILL to the negative pid, which is the same group kill (killProcessTree in dist/utils/shell.js). The only complete way to stop everything a command started is an operating system container or cgroup (a Linux feature that groups processes and limits what they can do), which Permissions, Sandboxing, and Prompt Injection covers.

Example 7: truncating output for the model

A test run, a build log, or cat on a large file can print megabytes. All of it would go into the model's context window, the fixed number of tokens (small pieces of text) that the model can attend to at once. A large output would push the rest of the conversation out and cost money. The shell tool therefore sends a bounded excerpt.

Which part to keep depends on how commands behave. The beginning of the output usually shows what ran. The end usually shows the error and the summary. The common policy keeps a head (the first lines) and a tail (the last lines) and replaces the middle with a marker, a line that says how much was removed.

The program below applies two limits, a line limit and a character limit, to 5,000 lines of output and to one very long line. The character limit is needed because a single line can be huge, for example minified JavaScript.

Java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class TruncateOutput {
    record Truncated(String text, int totalLines, int totalChars, boolean truncated) {}

    // Keep the first headLines and last tailLines; also cap the total characters.
    static Truncated headTail(String output, int headLines, int tailLines, int maxChars) {
        List<String> lines = output.lines().toList();
        int n = lines.size();
        String text;
        boolean cut = n > headLines + tailLines;
        if (cut) {
            String head = String.join("\n", lines.subList(0, headLines));
            String tail = String.join("\n", lines.subList(n - tailLines, n));
            text = head + "\n[... " + (n - headLines - tailLines) + " lines omitted ...]\n" + tail;
        } else {
            text = String.join("\n", lines);
        }
        if (text.length() > maxChars) {                      // very long lines: cut by characters too
            int half = maxChars / 2;
            text = text.substring(0, half) + "\n[... " + (text.length() - 2 * half) + " characters omitted ...]\n"
                    + text.substring(text.length() - half);
            cut = true;
        }
        return new Truncated(text, n, output.length(), cut);
    }

    static String run(String command) throws Exception {
        Process p = new ProcessBuilder(List.of("bash", "-c", command)).redirectErrorStream(true).start();
        String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        p.waitFor();
        return out;
    }

    public static void main(String[] args) throws Exception {
        String big = run("for i in $(seq 1 5000); do echo \"line $i\"; done; echo 'ERROR: test failed at line 4999' >&2");
        Truncated t = headTail(big, 3, 3, 2_000);
        System.out.println(t.text());
        System.out.println("total lines=" + t.totalLines() + " chars=" + t.totalChars() + " sent chars=" + t.text().length());

        Path spill = Files.createTempFile("shell-output", ".log");   // full output for a later read
        Files.writeString(spill, big);
        System.out.println("full output saved: " + Files.size(spill) + " bytes");
        Files.delete(spill);

        Truncated wide = headTail(run("head -c 5000 /dev/zero | tr '\\0' 'a'; echo"), 3, 3, 60);
        System.out.println(wide.text());
        System.out.println("one line of " + wide.totalChars() + " chars -> " + wide.text().length() + " chars");
    }
}
Output
line 1
line 2
line 3
[... 4995 lines omitted ...]
line 4999
line 5000
ERROR: test failed at line 4999
total lines=5001 chars=48925 sent chars=101
full output saved: 48925 bytes
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
[... 4940 characters omitted ...]
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
one line of 5001 chars -> 95 chars

Here is how to read the output.

  • The first seven lines are the excerpt sent to the model. line 1 to line 3 are the head, [... 4995 lines omitted ...] is the marker, and line 4999, line 5000, and the ERROR line are the tail. The error line was written to stderr after everything else, and because the streams are merged it arrives last, inside the tail. A head-only policy would have dropped it.
  • total lines=5001 chars=48925 sent chars=101. The full output has 5,001 lines (5,000 numbered lines plus the error line) and 48,925 characters. The model receives 101 characters: 3 lines of head, the marker, and 3 lines of tail. The marker states the number of omitted lines so that the model knows the output was cut and can ask for a part of it, for example with sed -n '2000,2100p' on a saved log.
  • full output saved: 48925 bytes. The full text goes to a temporary file. A real tool puts the file's path in the marker, so the model can read the omitted middle with a file tool instead of rerunning the command.
  • The last four lines show the second limit. One line of 5,000 a characters passes the line limit, because it is a single line. But the character limit of 60 keeps 30 characters from each end and replaces 4,940 with a marker (5,000 minus 60). Without a character limit, minified JavaScript or a long JSON line would bypass the line limit.

Lines bound how much must be scanned, and characters bound how many tokens the output costs. The reference harnesses make the same choices with different numbers.

  • Crush (v0.97.1, internal/agent/tools/bash.go) keeps the first and last 15,000 characters of a 30,000-character limit, counts the dropped lines in the marker, and writes the full output to a spill file whose path it puts in the marker.
  • Pi keeps the last 2,000 lines or 50 KB, whichever limit is reached first, and saves the full output to a temporary file.

Context Engineering for Coding Agents compares truncation policies across all tools.

Example 8: keeping the working directory between calls

Each tool call starts a new bash process, so a cd in one call is gone in the next. A model that runs cd src in one call and javac Main.java in the next call expects the second command to run in src. If the tool forgets, the second command fails with "file not found", and the model gets confused.

The shell tool can keep the directory by asking bash for its final working directory ($PWD) when the command ends, and by starting the next call there. The program below does this with an EXIT trap. A trap is a command that bash runs when some event happens, and an EXIT trap runs when the shell exits for any reason, including an explicit exit. The trap writes the directory to a file, and Java reads the file after the process ends.

The program also checks that the new directory is still inside the workspace. If a command moves outside, the session refuses the new directory.

Java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;

public class KeepCwd {
    static final class Session {
        final Path workspace;
        final Path cwdFile;
        Path cwd;

        Session(Path workspace) throws Exception {
            this.workspace = workspace.toRealPath();
            this.cwd = this.workspace;
            this.cwdFile = Files.createTempFile("cwd", ".txt");
        }

        String run(String command) throws Exception {
            // The EXIT trap runs after the command, even when it calls `exit`, and records $PWD.
            String wrapped = "trap 'pwd -P > \"$LOOP_CWD_FILE\"' EXIT\n" + command;
            ProcessBuilder pb = new ProcessBuilder(List.of("bash", "-c", wrapped))
                    .directory(cwd.toFile()).redirectErrorStream(true);
            pb.environment().put("LOOP_CWD_FILE", cwdFile.toString());
            Process p = pb.start();
            String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip();
            int code = p.waitFor();
            Path next = Path.of(Files.readString(cwdFile).strip());
            String note = "";
            if (next.startsWith(workspace) && Files.isDirectory(next)) cwd = next;
            else note = " (left the workspace; cwd not changed)";
            return "exit=" + code + " out=" + (out.isEmpty() ? "-" : out) + " cwd=" + show(cwd) + note;
        }

        String show(Path p) { return "/" + workspace.relativize(p).toString().replace('\\', '/'); }
    }

    public static void main(String[] args) throws Exception {
        Path ws = Files.createTempDirectory("ws");
        Files.createDirectories(ws.resolve("src/main/java"));
        Session s = new Session(ws);
        System.out.println("1 " + s.run("cd src"));
        System.out.println("2 " + s.run("ls"));
        System.out.println("3 " + s.run("cd main/java && pwd | sed 's|.*/src|<ws>/src|'"));
        System.out.println("4 " + s.run("cd ../..; exit 4"));
        System.out.println("5 " + s.run("cd /tmp"));
        System.out.println("6 " + s.run("cd no-such-dir"));
        try (Stream<Path> w = Files.walk(ws)) { w.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); }
        Files.delete(s.cwdFile);
    }
}
Output
1 exit=0 out=- cwd=/src
2 exit=0 out=main cwd=/src
3 exit=0 out=<ws>/src/main/java cwd=/src/main/java
4 exit=4 out=- cwd=/src
5 exit=0 out=- cwd=/src (left the workspace; cwd not changed)
6 exit=1 out=bash: line 2: cd: no-such-dir: No such file or directory cwd=/src

The workspace contains src/main/java. Each numbered line is one call, and cwd is shown relative to the workspace root.

  1. cd src prints nothing, exits 0, and the session's directory becomes /src.
  2. ls runs in /src without a cd, and prints main. The directory carried over from the first call.
  3. cd main/java && pwd moves two levels down. The sed replaces the random temporary prefix with <ws> so that the line is the same on every run.
  4. cd ../..; exit 4 goes back up and exits with code 4. The EXIT trap still ran, so the directory is recorded as /src, and the exit code is the command's 4. The trap does not change the exit status.
  5. cd /tmp leaves the workspace. The command succeeds, but the session refuses the new directory and keeps /src, with a note that the model will see. The check uses the real path, because pwd -P resolves symbolic links. A link inside the workspace that points outside it is therefore caught too.
  6. cd no-such-dir fails with exit code 1. Bash's message says line 2 because the trap is line 1 of the string bash runs and the command is line 2. The directory stays /src.

There are two other designs for the same problem.

  • A persistent shell keeps one bash process for the whole session and writes each command to its standard input, followed by a marker such as echo __DONE_$?__ that shows where the output ends. It keeps all state: directory, variables, functions, and activated virtual environments. It also keeps damage. A command that changes set -e or hangs affects every later call, and timing out one command without killing the shell is harder.
  • An interpreter in the agent runs shell syntax without starting bash at all. Crush does this with the mvdan.cc/sh interpreter (internal/shell/shell.go), and copies the interpreter's directory and exported variables back into its session after each command.

Pi runs every command in the same fixed directory and does not carry cd between calls. The trap approach in this example keeps only the directory, which is the state models most often rely on, and leaves each call otherwise independent.

Example 9: environment hygiene

A child process inherits the agent's environment unless the agent changes it. The environment of a developer's terminal often holds credentials: API keys, cloud profiles, a database password, the socket of an SSH agent. A command the model runs can read and print all of them. So can a test, a build script, or a dependency's post-install script. The printed text goes to the model, and possibly to a remote API.

The second problem is interactivity. Some programs open a pager (a program such as less that shows text one screen at a time and waits for keys) or ask for a password when they think a person is present.

The program below builds a child environment that removes credentials by name and sets variables that turn interactive behavior off. The parent environment in the program is made up, so the output is the same on every machine.

Java
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;

public class CleanEnvironment {
    static final Pattern SECRET = Pattern.compile(
            "(?i).*(TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIALS).*|AWS_.*|SSH_AUTH_SOCK");

    static final Map<String, String> NON_INTERACTIVE = Map.of(
            "GIT_TERMINAL_PROMPT", "0",      // git fails instead of asking for a username
            "GIT_PAGER", "cat",              // git log/diff print instead of opening less
            "PAGER", "cat",
            "TERM", "dumb",                  // no cursor movement or full-screen programs
            "NO_COLOR", "1",                 // programs that follow no-color.org print no colors
            "DEBIAN_FRONTEND", "noninteractive");

    static Map<String, String> cleanEnv(Map<String, String> parent, List<String> removed) {
        Map<String, String> env = new TreeMap<>();
        parent.forEach((k, v) -> { if (SECRET.matcher(k).matches()) removed.add(k); else env.put(k, v); });
        env.putAll(NON_INTERACTIVE);
        return env;
    }

    public static void main(String[] args) throws Exception {
        Map<String, String> parent = new TreeMap<>(Map.of(
                "PATH", System.getenv("PATH"), "HOME", "/home/dev", "LANG", "C.UTF-8",
                "OPENAI_API_KEY", "sk-test-123", "GITHUB_TOKEN", "ghp_test", "AWS_PROFILE", "prod",
                "DB_PASSWORD", "hunter2", "SSH_AUTH_SOCK", "/tmp/ssh-agent.sock", "TERM", "xterm-256color"));
        List<String> removed = new java.util.ArrayList<>();
        Map<String, String> env = cleanEnv(parent, removed);
        System.out.println("removed: " + removed.stream().sorted().toList());
        System.out.println("kept:    " + env.keySet());

        ProcessBuilder pb = new ProcessBuilder(List.of("bash", "-c",
                "echo \"key=${OPENAI_API_KEY:-unset} term=$TERM pager=$GIT_PAGER home=$HOME\""));
        pb.environment().clear();                       // start from nothing, not from the JVM's environment
        pb.environment().putAll(env);
        Process p = pb.redirectErrorStream(true).start();
        System.out.println("child:   " + new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip());
        p.waitFor();
    }
}
Output
removed: [AWS_PROFILE, DB_PASSWORD, GITHUB_TOKEN, OPENAI_API_KEY, SSH_AUTH_SOCK]
kept:    [DEBIAN_FRONTEND, GIT_PAGER, GIT_TERMINAL_PROMPT, HOME, LANG, NO_COLOR, PAGER, PATH, TERM]
child:   key=unset term=dumb pager=cat home=/home/dev

The parent map stands in for a developer's environment. Its values are fake, which keeps the output the same on every machine.

  • removed: lists the five names that matched the pattern. OPENAI_API_KEY and GITHUB_TOKEN match by words inside their names (API_KEY, TOKEN). DB_PASSWORD matches by the word PASSWORD. AWS_PROFILE matches the AWS_ prefix, because the AWS command-line tools read credentials and profiles from AWS_* variables. SSH_AUTH_SOCK matches by exact name. That last one holds no secret text, but it lets any process use the keys loaded into the user's SSH agent, for example to push to a repository.
  • kept: is sorted because the map is a TreeMap. PATH, HOME, and LANG came from the parent. The six non-interactive settings were added, and TERM was replaced by dumb.
  • child: is what the child process sees. The key is unset, TERM is dumb, and the pager is cat. The program calls pb.environment().clear() before putAll, so the child gets exactly this map and nothing from the JVM's real environment.

Each added variable turns one behavior off.

Variable Effect
GIT_TERMINAL_PROMPT=0 git fails instead of asking for a username or password on the terminal (git documentation)
GIT_PAGER=cat, PAGER=cat git log, git diff, and man print their output instead of opening a pager that waits for keys
TERM=dumb programs that read TERM skip cursor movement and full-screen modes
NO_COLOR=1 programs that follow the no-color.org convention print no color codes
DEBIAN_FRONTEND=noninteractive Debian's package tools use default answers instead of dialogs

A pattern on names is a denylist. It removes what it recognizes, so a variable named MY_SERVICE_CREDS passes through. An allowlist is stricter: start from an empty map and copy only PATH, HOME, LANG, USER, TMPDIR, and whatever the project's build needs. An allowlist can break tools that need a variable you forgot, but the error message usually names that variable, so the fix is quick.

The login shell from Example 1 matters here. bash -lc runs the user's profile, which may export the same secrets again after the agent removed them. Removing variables also gives no protection against a command that reads ~/.aws/credentials or ~/.ssh/id_ed25519 from disk. File access is a sandboxing question for Permissions, Sandboxing, and Prompt Injection.

Example 10: commands that wait for input

The model cannot type. A command that asks a question (Overwrite? [y/N], a password prompt, or git commit opening an editor) waits for an answer that never comes, and the tool call hangs until its timeout.

The shell tool prevents most of these hangs by giving the child no input at all. It does this by connecting standard input to /dev/null, a special file that is always empty. Any program that tries to read from it receives end-of-input at once, the signal that no more data will arrive, so it stops waiting.

The program below runs the same prompt twice, first with an open pipe that nobody writes to and then with /dev/null. It then runs cat, and finally a program that reads the terminal directly instead of standard input.

Java
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class WaitsForInput {
    enum Stdin { OPEN_PIPE, DEV_NULL }

    static String run(String command, Stdin stdin, boolean newSession) throws Exception {
        List<String> argv = new ArrayList<>(newSession ? List.of("setsid") : List.of());
        argv.addAll(List.of("bash", "-c", command));
        ProcessBuilder pb = new ProcessBuilder(argv).redirectErrorStream(true);
        if (stdin == Stdin.DEV_NULL) pb.redirectInput(new File("/dev/null"));
        Process p = pb.start();                         // OPEN_PIPE: we hold the pipe and never write
        if (!p.waitFor(2, TimeUnit.SECONDS)) {
            p.descendants().forEach(ProcessHandle::destroyForcibly);
            p.destroyForcibly().waitFor();
            return "TIMED OUT (waiting for input)";
        }
        return new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip().replace("\n", " | ");
    }

    public static void main(String[] args) throws Exception {
        String ask = "printf 'Overwrite? [y/N] '; read -r answer; echo \"answer=[$answer] read-status=$?\"";
        System.out.println("open pipe: " + run(ask, Stdin.OPEN_PIPE, false));
        System.out.println("/dev/null: " + run(ask, Stdin.DEV_NULL, false));
        System.out.println("cat:       " + run("cat; echo cat-exit=$?", Stdin.DEV_NULL, false));
        System.out.println("/dev/tty:  " + run("echo 'Password:' > /dev/tty; read -r pw < /dev/tty", Stdin.DEV_NULL, true));
    }
}
Output
open pipe: TIMED OUT (waiting for input)
/dev/null: Overwrite? [y/N] answer=[] read-status=1
cat:       cat-exit=0
/dev/tty:  bash: line 1: /dev/tty: No such device or address | bash: line 1: /dev/tty: No such device or address

Here is what each line shows.

  • open pipe: TIMED OUT (waiting for input). ProcessBuilder connects the child's standard input to a pipe by default. Java never writes to it and never closes it, so read waits for a line or for end-of-input, and neither comes. The two-second timeout ends the wait.
  • /dev/null: Overwrite? [y/N] answer=[] read-status=1. redirectInput(new File("/dev/null")) makes standard input the null device, which returns end-of-input on the first read. read gives up at once with status 1, the answer is empty, and the script continues. A script that defaults to "no" behaves correctly here. Closing Java's end of the pipe right after start() (Example 2) has the same effect.
  • cat: cat-exit=0. cat with no file arguments copies standard input to standard output. A model sometimes runs it by mistake, for example cat followed by a heredoc (a block of text fed to a command) that got lost. With /dev/null, cat ends at once with nothing to copy, instead of hanging.
  • /dev/tty: ... No such device or address, twice. /dev/tty is the controlling terminal, the terminal a process belongs to. Programs that must talk to a person, such as sudo and ssh asking for a password, open /dev/tty instead of reading standard input, so redirecting stdin does not stop them. Under setsid the process has no controlling terminal, so opening /dev/tty fails right away. The two messages come from the echo and the read, each failing to open it.

Without setsid, a child of a JVM that runs in a terminal can open that terminal, write a password prompt onto the user's screen, and wait for keystrokes. Several defenses therefore work together:

  • /dev/null for standard input.
  • setsid to remove the terminal.
  • The non-interactive variables from Example 9 for pagers and git.
  • A timeout for everything else.

The tool description should also tell the model that no input is available. The model then adds flags such as -y, --yes, or --no-edit itself.

Example 11: background processes: start, log, check, stop

Some commands are meant to keep running: a development server that the tests talk to, a file watcher, a database. With a normal call the tool would wait until the command ends, which never happens. The model needs to start one, continue with other calls, read its output later, and stop it.

A background job is a process the tool starts without waiting for it, with an id the model uses in later calls. The program below keeps jobs in a map and sends each job's output to a log file. It offers four operations: start, status, tail (the last lines of the log), and stop.

Java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class BackgroundJobs {
    record Job(String id, String command, Process process, Path log) {}

    static final class Jobs {
        private final Map<String, Job> jobs = new TreeMap<>();
        private final Path logDir;
        private int next = 1;

        Jobs(Path logDir) { this.logDir = logDir; }

        String start(String command) throws Exception {
            String id = "job" + next++;
            Path log = logDir.resolve(id + ".log");
            Process p = new ProcessBuilder(List.of("setsid", "bash", "-c", command))
                    .redirectErrorStream(true)
                    .redirectOutput(log.toFile())             // the OS writes the log; no reader thread needed
                    .redirectInput(ProcessBuilder.Redirect.from(Path.of("/dev/null").toFile()))
                    .start();
            jobs.put(id, new Job(id, command, p, log));
            return id;
        }

        String status(String id) {
            Process p = jobs.get(id).process();
            return p.isAlive() ? "running" : "exited " + p.exitValue();
        }

        List<String> tail(String id, int n) throws Exception {
            List<String> lines = Files.readAllLines(jobs.get(id).log());
            return lines.subList(Math.max(0, lines.size() - n), lines.size());
        }

        String stop(String id) throws Exception {
            Process p = jobs.get(id).process();
            if (!p.isAlive()) return "already " + status(id);
            new ProcessBuilder("kill", "-TERM", "--", "-" + p.pid()).start().waitFor();   // whole group
            p.waitFor();
            return "stopped, " + status(id);
        }

        void stopAll() throws Exception { for (String id : jobs.keySet()) stop(id); }
    }

    static void awaitLine(Path log, String text) throws Exception {
        for (int i = 0; i < 1000 && !Files.readString(log).contains(text); i++) Thread.sleep(10);
    }

    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("jobs");
        Jobs jobs = new Jobs(dir);
        String server = jobs.start("echo 'server: starting'; echo 'server: listening'; while true; do sleep 1; done");
        String build = jobs.start("echo 'build: compiling'; echo 'build: 1 error' >&2; exit 2");
        awaitLine(dir.resolve(server + ".log"), "listening");
        jobs.jobs.get(build).process().waitFor();
        System.out.println(server + ": " + jobs.status(server) + " " + jobs.tail(server, 1));
        System.out.println(build + ": " + jobs.status(build) + " " + jobs.tail(build, 2));
        System.out.println(server + ": " + jobs.stop(server));
        System.out.println(build + ": " + jobs.stop(build));
        jobs.stopAll();
        for (String f : new String[] {"job1.log", "job2.log"}) Files.delete(dir.resolve(f));
        Files.delete(dir);
    }
}
Output
job1: running [server: listening]
job2: exited 2 [build: compiling, build: 1 error]
job1: stopped, exited 143
job2: already exited 2

Here is what each line shows.

  • job1: running [server: listening]. The server job printed two lines and is still in its loop. tail(server, 1) returns the last line of its log. The program waited for the word listening in the log before it checked, which is how an agent knows that a server is ready.
  • job2: exited 2 [build: compiling, build: 1 error]. The build job printed one line to stdout and one to stderr and exited with code 2. Both lines are in the same log because of redirectErrorStream(true), and status reports the exit code once the process has ended.
  • job1: stopped, exited 143. stop sends SIGTERM to the job's process group (negative pid, as in Example 6), so the sleep 1 inside the loop dies as well as bash. Exit code 143 is SIGTERM.
  • job2: already exited 2. Stopping a finished job is not an error. The tool reports the state.

Each job writes to a file through redirectOutput(log.toFile()), so the kernel writes the log, and no Java thread has to drain a pipe for the life of the job (Example 3). Each job also runs under setsid with /dev/null as input. Stopping it therefore reaches every process it started, and it never waits for input. The agent stops all jobs when the session ends. Otherwise a development server keeps its port after the agent exits.

The two reference harnesses differ here.

  • Crush's bash tool has a run_in_background parameter, moves a command that runs longer than auto_background_after seconds (default 60) to the background automatically, and offers separate job_output and job_kill tools.
  • Pi has no background mode. Its README says "No background bash. Use tmux." and leaves long-running processes to a terminal multiplexer that the user can see.

Example 12: streaming output to the user while capturing it

A test suite can run for minutes. The person watching the agent wants to see progress while it runs, and the model needs the final text. The tool therefore does two things with each line. It hands the line to a listener, a function that the terminal UI provides, as soon as the line arrives. It also appends a cleaned copy to the text that the model will receive.

Cleaning is needed because terminal programs write things that make sense on a screen but not in a transcript.

  • ANSI escape codes are byte sequences that start with the escape character (code 27) and change colors or move the cursor. A red FAIL is really the bytes for "switch to red", the text FAIL, and the bytes for "reset".
  • Carriage returns (\r) move the cursor to the start of the line. A progress bar uses them to redraw itself in place, so one printed line can hold many versions of the bar.

The program below reads one character at a time, splits only on \n, shows each line live, and builds a cleaned copy for the model.

Java
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.Consumer;
import java.util.regex.Pattern;

public class StreamAndCapture {
    static final Pattern ANSI = Pattern.compile("\u001B\\[[0-9;?]*[A-Za-z]");

    // For the model: drop color codes and keep only the last redraw of a \r progress line.
    static String forModel(String line) {
        String plain = ANSI.matcher(line).replaceAll("");
        int cr = plain.lastIndexOf('\r');
        return cr >= 0 ? plain.substring(cr + 1) : plain;
    }

    static String runStreaming(String command, Consumer<String> onLine) throws Exception {
        Process p = new ProcessBuilder(List.of("bash", "-c", command)).redirectErrorStream(true).start();
        StringBuilder captured = new StringBuilder();
        try (Reader r = new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8)) {
            StringBuilder line = new StringBuilder();
            for (int c; (c = r.read()) != -1; ) {           // split on \n only; readLine() would also split on \r
                if (c != '\n') { line.append((char) c); continue; }
                onLine.accept(line.toString());              // the user sees each line as it arrives
                captured.append(forModel(line.toString())).append('\n');
                line.setLength(0);
            }
            if (!line.isEmpty()) { onLine.accept(line.toString()); captured.append(forModel(line.toString())).append('\n'); }
        }
        captured.append("[exit code ").append(p.waitFor()).append(']');
        return captured.toString();
    }

    public static void main(String[] args) throws Exception {
        String cmd = "echo 'Compiling 3 files'; "
                + "printf 'download  10%%\\rdownload  60%%\\rdownload 100%%\\n'; "
                + "printf '\\033[32mPASS\\033[0m test_add\\n'; "
                + "printf '\\033[31mFAIL\\033[0m test_div\\n' >&2; exit 1";
        int[] count = {0};
        String forModel = runStreaming(cmd, line -> {
            count[0]++;
            String visible = line.replace("\u001B", "ESC").replace("\r", "\\r");
            System.out.println("live " + count[0] + ": " + visible);
        });
        System.out.println("--- captured for the model ---");
        System.out.println(forModel);
    }
}
Output
live 1: Compiling 3 files
live 2: download  10%\rdownload  60%\rdownload 100%
live 3: ESC[32mPASSESC[0m test_add
live 4: ESC[31mFAILESC[0m test_div
--- captured for the model ---
Compiling 3 files
download 100%
PASS test_add
FAIL test_div
[exit code 1]

Here is how the two views differ.

  • live 1 to live 4 are what the listener received, one call per line, as the process produced them. The program prints the escape character as ESC and the carriage return as \r so that they are visible.
  • live 2 is one line that contains three redraws of a progress indicator: download 10%, then \r, then download 60%, and so on. A terminal shows only the last one. The reader splits only on \n. BufferedReader.readLine() would also split at \r and turn one progress line into three.
  • live 3 and live 4 contain color codes: ESC[32m (green), ESC[31m (red), and ESC[0m (reset).
  • The block after --- captured for the model --- is the cleaned text. The progress line became download 100%, the text after the last \r. The color codes are gone, so PASS test_add and FAIL test_div cost a few tokens each instead of carrying escape sequences. The last line, [exit code 1], comes from the exit 1 at the end of the command.

Many programs buffer standard output in blocks of several kilobytes when it is a pipe rather than a terminal, so lines can arrive in bursts. The C library's stdio does this. Python does it unless it runs with -u or PYTHONUNBUFFERED=1. GNU coreutils' stdbuf -oL asks a stdio program for line buffering. The captured text is complete either way. Only the timing of the live view changes.

Example 13: reporting exit codes and output to the model

The last step turns a finished command into the tool result, the text that the model reads in the next turn, plus a flag that says whether the call failed. The format should leave no room for guessing. The model must be able to tell whether the command succeeded, what its exit code was, whether the output was cut, and what the command printed.

The program below formats four outcomes the same way: a success with no output, a failure with an error message, a command that exits with 1 and prints nothing, and a command that times out. The status goes first, because the model reads the top of the text before the output.

Java
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class ReportToModel {
    record ShellResult(String command, int exitCode, String output, boolean timedOut, long timeoutMs) {}
    record ToolResult(String content, boolean isError) {}

    static ShellResult run(String command, long timeoutMs) throws Exception {
        Process p = new ProcessBuilder(List.of("setsid", "bash", "-c", command))
                .redirectErrorStream(true)
                .redirectInput(ProcessBuilder.Redirect.from(new java.io.File("/dev/null"))).start();
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        Thread reader = Thread.ofVirtual().start(() -> { try { p.getInputStream().transferTo(out); } catch (Exception e) { } });
        boolean done = p.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
        if (!done) new ProcessBuilder("kill", "-KILL", "--", "-" + p.pid()).start().waitFor();
        int code = p.waitFor();
        reader.join();
        return new ShellResult(command, code, out.toString().strip(), !done, timeoutMs);
    }

    // One text block the model can read without guessing: status line first, then the output.
    static ToolResult report(ShellResult r) {
        StringBuilder sb = new StringBuilder();
        if (r.timedOut()) sb.append("Command timed out after ").append(r.timeoutMs()).append(" ms and was killed. Partial output:\n");
        else sb.append("Exit code: ").append(r.exitCode()).append('\n');
        sb.append(r.output().isEmpty() ? "(no output)" : r.output());
        return new ToolResult(sb.toString(), r.timedOut() || r.exitCode() != 0);
    }

    public static void main(String[] args) throws Exception {
        String[] commands = {
            "mkdir -p build && rmdir build",
            "echo 'Main.java:3: error: cannot find symbol' >&2; exit 1",
            "printf 'a\\nb\\n' | grep -n zzz",
            "echo 'starting tests'; sleep 30",
        };
        for (String c : commands) {
            ToolResult t = report(run(c, 3000));
            System.out.println("$ " + c + "\nisError=" + t.isError() + "\n" + t.content() + "\n");
        }
    }
}
Output
$ mkdir -p build && rmdir build
isError=false
Exit code: 0
(no output)

$ echo 'Main.java:3: error: cannot find symbol' >&2; exit 1
isError=true
Exit code: 1
Main.java:3: error: cannot find symbol

$ printf 'a\nb\n' | grep -n zzz
isError=true
Exit code: 1
(no output)

$ echo 'starting tests'; sleep 30
isError=true
Command timed out after 3000 ms and was killed. Partial output:
starting tests

Each block shows the command, the isError flag, and the result text.

  • mkdir -p build && rmdir build succeeded and printed nothing. The result says Exit code: 0 and (no output). An empty string would also be correct, but some models answer an empty tool result by running the command again, and the explicit text avoids that.
  • The second command printed a compiler-style error to stderr and exited 1. Because the output is merged, the error text follows the status line, and isError=true.
  • grep -n zzz found no match. The result is Exit code: 1 and (no output), and isError=true. This is the ambiguity from Example 2: for grep, 1 means "no lines matched", which is an answer and not a malfunction. The tool cannot know every program's conventions, so it reports the exit code exactly and lets the model interpret it. Marking non-zero codes as errors is a choice. It helps for compilers and test runners, and it is slightly misleading for grep, diff, and test.
  • The last command printed starting tests and then slept. After 3,000 ms the tool killed the process group and reported Command timed out after 3000 ms and was killed. Partial output: followed by what had been printed. Partial output tells the model how far the command got, which decides whether it should raise the timeout or change the command.

isError maps to the error flag of the tool-result message in the protocol the agent uses, for example is_error in some providers' tool-result blocks or isError in an MCP tool result. With the flag, the model can tell a failed call from a successful one without parsing text.

The two reference harnesses make different choices here.

  • Crush appends Exit code N after the output, adds the working directory as <cwd>...</cwd>, and returns the text as a normal (not error) result.
  • Pi throws from the tool's execute when the exit code is non-zero, which its runtime reports to the model as a result with isError: true (Pi's extension documentation). It appends Command exited with code N and returns Command timed out after N seconds for timeouts.

Example 14: Project 23, the shell tool in a scripted session

Project 23 puts Examples 1 to 13 into one class, ShellTool, that implements the Tool interface. The program runs it in a small agent loop against a scripted model, a list of canned assistant messages that stands in for a real model, so that the run is the same every time.

The task for the session is: write a Java file, compile it, read the compiler error, fix the file, compile again, and run it. The first message contains a deliberate bug, a missing semicolon. The scripted model's second message is written to fit the compiler error that the first call produces. In a real run, the model would read the error and write that fix itself.

Look for three things in the output: the error result of the first call, the working directory that carries over to the second call, and the final history size.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

public class ShellToolProject {
    static final ObjectMapper JSON = new ObjectMapper();

    // Restated from Project 21 (The Agent Loop and a Tool Framework), reduced to what this program needs.
    interface Tool { String name(); String description(); JsonNode schema(); String run(JsonNode args) throws Exception; }
    sealed interface Message permits UserMessage, AssistantMessage, ToolMessage {}
    record UserMessage(String text) implements Message {}
    record ToolCall(String id, String name, String arguments) {}
    record AssistantMessage(String text, List<ToolCall> toolCalls) implements Message {}
    record ToolMessage(String callId, String name, String content, boolean isError) implements Message {}

    static ToolMessage execute(Map<String, Tool> tools, ToolCall call) {     // Project 21: exceptions become error results
        try {
            return new ToolMessage(call.id(), call.name(), tools.get(call.name()).run(JSON.readTree(call.arguments())), false);
        } catch (Exception e) {
            return new ToolMessage(call.id(), call.name(), e.getClass().getSimpleName() + ": " + e.getMessage(), true);
        }
    }

    static final class CommandFailed extends Exception { CommandFailed(String text) { super(text); } }

    static final class ShellTool implements Tool {
        private final Path workspace;
        private final Path cwdFile;
        private Path cwd;
        private final long defaultTimeoutMs;
        private final int maxChars;

        ShellTool(Path workspace, long defaultTimeoutMs, int maxChars) throws Exception {
            this.workspace = workspace.toRealPath();
            this.cwd = this.workspace;
            this.cwdFile = Files.createTempFile("loop-cwd", ".txt");
            this.defaultTimeoutMs = defaultTimeoutMs;
            this.maxChars = maxChars;
        }

        public String name() { return "shell"; }
        public String description() {
            return "Run a bash command in the workspace. The working directory persists between calls. "
                    + "No input is available; commands that prompt fail. Output is truncated to " + maxChars + " characters.";
        }
        public JsonNode schema() {
            ObjectNode s = JSON.createObjectNode().put("type", "object");
            ObjectNode props = s.putObject("properties");
            props.putObject("command").put("type", "string").put("description", "bash command line");
            props.putObject("timeout_ms").put("type", "integer").put("minimum", 1);
            s.putArray("required").add("command");
            return s;
        }

        public String run(JsonNode args) throws Exception {
            String command = args.path("command").asText("");
            if (command.isBlank()) throw new IllegalArgumentException("missing required argument 'command'");
            long timeoutMs = args.path("timeout_ms").asLong(defaultTimeoutMs);
            ProcessBuilder pb = new ProcessBuilder(List.of("setsid", "bash", "-c",
                    "trap 'pwd -P > \"$LOOP_CWD_FILE\"' EXIT\n" + command))
                    .directory(cwd.toFile()).redirectErrorStream(true)
                    .redirectInput(ProcessBuilder.Redirect.from(new File("/dev/null")));
            Map<String, String> env = pb.environment();
            env.keySet().removeIf(k -> k.matches("(?i).*(TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY).*|AWS_.*"));
            env.keySet().removeAll(List.of("JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS")); // agent's JVM flags
            env.putAll(Map.of("LOOP_CWD_FILE", cwdFile.toString(), "GIT_TERMINAL_PROMPT", "0",
                    "PAGER", "cat", "GIT_PAGER", "cat", "TERM", "dumb", "NO_COLOR", "1"));
            Process p = pb.start();
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            Thread reader = Thread.ofVirtual().start(() -> { try { p.getInputStream().transferTo(out); } catch (Exception e) { } });
            boolean done = p.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
            if (!done) new ProcessBuilder("kill", "-KILL", "--", "-" + p.pid()).start().waitFor();
            int code = p.waitFor();
            reader.join();
            Path next = Path.of(Files.readString(cwdFile).strip());
            if (done && next.startsWith(workspace) && Files.isDirectory(next)) cwd = next;
            String text = truncate(out.toString(StandardCharsets.UTF_8).strip());
            String status = done ? "Exit code: " + code : "Timed out after " + timeoutMs + " ms; process group killed.";
            String where = "cwd: /" + workspace.relativize(cwd).toString().replace('\\', '/');
            String result = status + "\n" + where + "\n" + (text.isEmpty() ? "(no output)" : text);
            if (!done || code != 0) throw new CommandFailed(result);          // reported as an error result
            return result;
        }

        private String truncate(String s) {
            if (s.length() <= maxChars) return s;
            int half = maxChars / 2;
            return s.substring(0, half) + "\n[... " + (s.length() - 2 * half) + " characters omitted ...]\n" + s.substring(s.length() - half);
        }
    }

    // The scripted model: one canned assistant message per step.
    static AssistantMessage call(String id, String command) {
        return new AssistantMessage(null, List.of(new ToolCall(id, "shell", JSON.createObjectNode().put("command", command).toString())));
    }

    public static void main(String[] args) throws Exception {
        Path ws = Files.createTempDirectory("loop-ws");
        ShellTool shell = new ShellTool(ws, 20_000, 2_000);
        Map<String, Tool> tools = Map.of(shell.name(), shell);
        List<AssistantMessage> script = List.of(
            call("call_1", """
                mkdir -p src && cat > src/Hello.java <<'EOF'
                public class Hello {
                    public static void main(String[] args) {
                        System.out.println("sum = " + add(2, 3))
                    }
                    static int add(int a, int b) { return a + b; }
                }
                EOF
                cd src && javac Hello.java"""),
            call("call_2", """
                cat > Hello.java <<'EOF'
                public class Hello {
                    public static void main(String[] args) {
                        System.out.println("sum = " + add(2, 3));
                    }
                    static int add(int a, int b) { return a + b; }
                }
                EOF
                javac Hello.java && java Hello"""),
            new AssistantMessage("Hello.java compiles and prints sum = 5.", List.of()));

        List<Message> history = new ArrayList<>(List.of(new UserMessage("Write Hello.java in src that prints 2 + 3, compile it, and run it.")));
        for (AssistantMessage a : script) {                     // a real agent sends `history` to the model here
            history.add(a);
            if (a.toolCalls().isEmpty()) { System.out.println("assistant: " + a.text()); break; }
            for (ToolCall c : a.toolCalls()) {
                String cmd = JSON.readTree(c.arguments()).get("command").asText();
                System.out.println("tool call " + c.id() + ": shell " + cmd.lines().reduce((x, y) -> y).orElse(""));
                ToolMessage r = execute(tools, c);
                history.add(r);
                System.out.println("result (isError=" + r.isError() + "):\n" + r.content().indent(2).stripTrailing());
            }
        }
        System.out.println("messages in history: " + history.size());
        try (Stream<Path> w = Files.walk(ws)) { w.sorted(Comparator.reverseOrder()).forEach(f -> f.toFile().delete()); }
        Files.delete(shell.cwdFile);
    }
}
Output
tool call call_1: shell cd src && javac Hello.java
result (isError=true):
  CommandFailed: Exit code: 1
  cwd: /src
  Hello.java:3: error: ';' expected
          System.out.println("sum = " + add(2, 3))
                                                  ^
  1 error
tool call call_2: shell javac Hello.java && java Hello
result (isError=false):
  Exit code: 0
  cwd: /src
  sum = 5
assistant: Hello.java compiles and prints sum = 5.
messages in history: 6

The session has three model turns.

  • tool call call_1 shows the last line of the first command, cd src && javac Hello.java. The full command creates src, writes Hello.java with a heredoc (<<'EOF'; the quotes stop bash from expanding $ inside the file), changes into src, and compiles.
  • The result is an error result. run threw CommandFailed because the exit code was not 0, and the restated Project 21 execute method turned the exception into a ToolMessage with isError=true and the content CommandFailed: followed by the exception's message. The message is the full result text: Exit code: 1, the working directory /src (the cd succeeded before javac failed, and the EXIT trap recorded it), and javac's message Hello.java:3: error: ';' expected. javac also prints the source line and a caret under the position where the semicolon is missing, and 1 error is javac's count. This is the text a real model would read to find the fix.
  • tool call call_2 rewrites Hello.java with the semicolon, then runs javac Hello.java && java Hello. It uses a relative path because the working directory carried over from the first call.
  • The second result is Exit code: 0, still in /src, with the program's output sum = 5, and isError=false.
  • assistant: is the scripted final message with no tool calls, which ends the loop.
  • messages in history: 6 counts the user message, three assistant messages, and two tool messages. A real agent sends this history to the model before each assistant turn.

ShellTool.run applies the earlier examples in order. The list below follows the code from top to bottom.

  1. It throws IllegalArgumentException for a missing command, which the agent reports as an error result.
  2. It starts setsid bash -c with the EXIT trap (Examples 1, 6, 8) in the session's directory, with /dev/null as input (Example 10).
  3. It removes secret-looking variables and adds non-interactive ones (Example 9).
  4. It also removes JAVA_TOOL_OPTIONS, _JAVA_OPTIONS, and JDK_JAVA_OPTIONS. These carry JVM flags meant for the agent's own JVM. Every java and javac the model runs would pick them up, and the JVM prints a Picked up ... line on standard error each time it does, which would end up in the tool output.
  5. It drains the merged output on a virtual thread (Example 3), and on timeout it kills the whole process group (Examples 4 to 6).
  6. It updates the directory only after a normal exit inside the workspace (Example 8). After a SIGKILL the trap never ran, so the file holds the previous value.
  7. It truncates the output (Example 7) and builds the status-first result (Example 13).
  8. A non-zero exit code or a timeout makes it throw CommandFailed with that text. This is how Project 21's interface, whose run returns only a string, marks a result as an error (the isError choice of Example 13).

The schema() method gives the model two parameters, a required command string and an optional timeout_ms integer. The description() method tells the model the three facts it needs to use the tool well: the directory persists, there is no input, and output is truncated.

The version in loop adds streaming through an event listener (Example 12), background jobs as a second tool (Example 11), a spill file named in the truncation marker, and SIGTERM before SIGKILL with a grace period. The check that decides whether a command may run at all is the subject of Permissions, Sandboxing, and Prompt Injection.

Interview points

  • A shell tool starts bash -c <command> because pipes, redirection, &&, and globs are shell syntax, while ProcessBuilder starts one program with literal arguments.
  • bash -lc loads the user's profile and its PATH, costs startup time on every call, and can export removed secrets again. bash -c starts with exactly the environment the agent builds.
  • Every pipe the child writes to needs a reader from the start. A full 64 KiB pipe blocks the child, and then waitFor() never returns. Merging stderr into stdout keeps the order of lines and needs only one reader.
  • destroy() sends SIGTERM, which a program can catch or ignore, and destroyForcibly() sends SIGKILL, which it cannot. Java reports death by signal N as exit code 128 + N, for example 143 for SIGTERM and 137 for SIGKILL.
  • Killing the Process kills one process. ProcessHandle.descendants() finds the tree, but orphaned and daemonized processes leave it. A process group from setsid plus kill -- -pgid reaches them. Only containers or cgroups catch processes that call setsid() themselves.
  • Truncation keeps a head and a tail because errors and summaries are at the end. It needs both a line limit and a character limit, a marker with the omitted count, and ideally a saved copy of the full log.
  • Working-directory persistence can use an EXIT trap that records pwd -P, a persistent shell with end markers, or an in-process interpreter (Crush). Each approach keeps a different amount of state.
  • Standard input should be /dev/null and the process should have no controlling terminal (setsid), so prompts fail fast instead of hanging. GIT_TERMINAL_PROMPT=0 and PAGER=cat stop the common interactive cases.
  • Background jobs write to log files, so no thread has to drain a pipe for their whole life. They run in their own process group and are stopped when the session ends.
  • The tool result states the exit code, the timeout, and truncation explicitly, and sets an error flag. Exit code 1 from grep shows why the exact code matters more than a word like "success".

Summary

  • ProcessBuilder(List.of("bash", "-c", command)) runs a command line. directory(...) sets the working directory, and environment() is a per-child copy of the JVM's environment.
  • redirectErrorStream(true) merges stderr into stdout in the order the child wrote the lines.
  • Exit code 127 means command not found, 2 from bash means a syntax error, and 128 + N means death by signal N.
  • A Linux pipe holds 16 pages (65,536 bytes with 4 KiB pages) by default. A child that fills an unread pipe blocks until someone reads.
  • waitFor(timeout, unit) returns false on timeout. destroy() sends SIGTERM and destroyForcibly() sends SIGKILL.
  • A process that ignores SIGTERM survives destroy(), and ignored signals are inherited by the programs a shell starts.
  • Killing a parent does not kill its children. The orphans are reparented and leave descendants().
  • setsid puts a command in a new process group whose id is its pid, and kill -KILL -- -<pgid> signals every member, including processes that left the tree.
  • Truncation in Crush keeps 15,000 characters from each end of a 30,000-character limit. Pi keeps the last 2,000 lines or 50 KB.
  • An EXIT trap runs even after exit N and does not change the exit status, so it can record the final directory for the next call.
  • Removing secrets by name is a denylist. An allowlist of variables is stricter, and it breaks tools that need a variable that was forgotten.
  • /dev/null as standard input makes read return status 1 at once. Programs such as sudo read /dev/tty, which fails under setsid.
  • Background jobs log to files through redirectOutput(File) and are stopped by process group.
  • Project 23's ShellTool combines all of these behind the Tool interface. A scripted session exercises it by compiling a Java file, failing, fixing the file, and running it.