Basics

Commands and files: os/exec and os.Root for an agent’s tools

Run go test so it stops when told, keeps your keys, and reports what failed; keep file tools inside one directory.

Updated

Stage 3: Run the tools

A coding agent’s most useful tools reach outside the Go process. One runs go test on the code the model just changed; others read, search, and edit files in a workspace. These tools are where a model’s choices become real processes and real writes, so their limits have to hold no matter what the model asks for.

Two parts of the standard library carry most of that weight. os/exec starts a command, collects what it prints, and stops it when the run is cancelled or a deadline passes. os.Root opens one directory and refuses any path that would lead out of it. This page covers both, with code from the companion repository, where it is tested. Some of the pieces are recent:

Feature Since
exec.CommandContext Go 1.7
exec.Cmd.Cancel, exec.Cmd.WaitDelay, exec.ErrWaitDelay Go 1.20
os.Root, os.OpenRoot, os.OpenInRoot Go 1.24
root.ReadFile, root.WriteFile, root.MkdirAll, and nine more os.Root methods Go 1.25
errors.AsType Go 1.26

Go 1.26 and 1.27 added no methods to os.Root. The companion code needs Go 1.27.

Running a command from a tool

exec.CommandContext builds a command tied to a context. If the context ends before the command does, os/exec calls the command’s Cancel function, which CommandContext sets to kill the process; on Unix that is SIGKILL, which the process can’t catch. The context to pass is the one the tool was given, derived from the run’s, so Ctrl-C, a shutdown, and the tool’s own deadline all reach the process the same way. The context page covers that chain; here is the runner behind the agent’s run_tests tool:

ch17/agent/gotest.go
// GoTest runs go test in dir with args, such as a package pattern, and
// returns a report the model can read. The triage tools (chapter 7)
// call it on the agent's workspace.
func GoTest(ctx context.Context, dir string, timeout time.Duration,
	args ...string) (string, error) {
	ctx, cancel := context.WithTimeoutCause(ctx, timeout, ErrTestsTimedOut)
	defer cancel()
	inner := fmt.Sprintf("-timeout=%s", timeout*3/4)
	cmd := exec.CommandContext(ctx, "go", append([]string{"test", "-json",
		"-count=1", inner}, args...)...)
	cmd.Dir = dir
	cmd.Env = testEnv()
	if slices.Contains(args, "-race") {
		cmd.Env = append(cmd.Env, "CGO_ENABLED=1") // the race detector needs cgo
	}
	cmd.WaitDelay = 5 * time.Second
	var stdout, stderr bytes.Buffer
	cmd.Stdout, cmd.Stderr = &stdout, &stderr
	err := cmd.Run()

run_tests gives it two minutes, as a deadline with its own cause, ErrTestsTimedOut, so a timeout can be told apart from a cancelled run. go test gets a -timeout of three quarters of that, 90 seconds, so a hung test is usually stopped by go test itself, which names the test and prints its goroutines.

WaitDelay handles a problem that has nothing to do with the command’s own exit. When Stdout or Stderr is a Go writer, such as a bytes.Buffer, os/exec connects the child to a pipe and copies from it in a goroutine, and Run doesn’t return until that copy reaches the end of the pipe. Any process the command started inherits the pipe, so it can keep the pipe open after the command itself is dead. A short program shows the difference: a shell that started sleep 30 in the background was killed after half a second. With no WaitDelay, Run returned after 30 seconds, when sleep finally exited. With a WaitDelay of one second, Run returned after 1.5 seconds, by closing the pipe; sleep was still running. WaitDelay gets your goroutine back, but it does nothing about that leftover process, which is the next section’s subject.

Cancel can be replaced, as long as you set it before the command starts. A custom one might send SIGTERM to let the process exit on its own; if the process is still running when WaitDelay expires, os/exec kills it anyway. Sometimes killing is the wrong first step altogether. The agent’s MCP client starts a server process that it talks to over standard input and output, and the protocol asks a client to close the server’s input before anything harsher:

ch17/mcp/client.go
func Start(ctx context.Context, cmd Command,
	info Implementation) (*Client, error) {
	// Not exec.CommandContext: its cancel kills the process, and the
	// protocol asks a client to close the server's input first. Close
	// does that, then kills; ctx ending calls Close.
	p := exec.Command(cmd.Path, cmd.Args...) //nolint:noctx // see above
	p.Env = append([]string{}, cmd.Env...)   // nil would inherit ours
	p.Dir = cmd.Dir

So it uses plain exec.Command and arranges the stop itself, with context.AfterFunc, as the context page shows.

Stopping everything it started

go test is a parent: it runs the compiler, then a test binary, and test code can start processes of its own. The default Cancel kills only the process exec started. In a check with a test that sleeps for 40 seconds, killing the go command after four seconds left the test binary running, and Run waited out its two-second WaitDelay because the test binary still held the output pipe.

The triage agent has a hardened test runner, which it uses when its defenses are on. That runner starts the command in a group of its own and makes cancelling kill the group:

ch17/safety/group_unix.go
// group starts the command in a process group of its own, and makes
// cancelling it kill the whole group: go test starts a test binary, and
// the test can start more, none of which a kill of the go command alone
// would reach. A child that starts its own session leaves the group and
// escapes this; only a rung-3 sandbox (bubblewrap's --die-with-parent,
// a container) catches that.
func group(cmd *exec.Cmd) {
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	cmd.Cancel = func() error {
		return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
	}
}

// killGroup kills whatever is left in the group after the go command
// exits, such as a test's child that was still running.
func killGroup(cmd *exec.Cmd) {
	if cmd.Process != nil {
		// ESRCH, the only error, means none was left.
		_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
	}
}

Setpgid: true puts the child in a new group whose ID is the child’s own process ID, so -cmd.Process.Pid names the whole group. Every process go test starts joins that group unless it asks to leave. With this Cancel, the same check ended at four seconds with nothing left running. killGroup covers the other way a run ends: the go command exits normally while a child of the test is still alive.

Here is where both are used, along with the output handling the next section describes:

ch17/safety/sandbox.go
// Run runs go test with args in the workspace, for at most timeout, and
// returns its output, cut at MaxOutput as it is read. A timeout kills
// every process the run started, not only the go command.
func (t TestRunner) Run(ctx context.Context, timeout time.Duration,
	args ...string) (string, error) {
	if err := t.scratch(); err != nil {
		return "", err
	}
	ctx, cancel := context.WithTimeoutCause(ctx, timeout,
		ErrTestsTimedOut)
	defer cancel()
	name, argv := t.command(args)
	cmd := exec.CommandContext(ctx, name, argv...)
	cmd.Dir = t.Dir
	cmd.Env = append(t.env(), t.Env...)
	if slices.Contains(args, "-race") {
		// The race detector needs cgo.
		cmd.Env = append(cmd.Env, "CGO_ENABLED=1")
	}
	out := &capped{max: cmpInt(t.MaxOutput, 1<<20)}
	cmd.Stdout, cmd.Stderr = out, out
	group(cmd)
	cmd.WaitDelay = 2 * time.Second
	err := cmd.Run()
	killGroup(cmd)
	return out.String(), runError(ctx, err, out.over)
}

Three limits are worth stating plainly:

  • It is Unix only. group_unix.go has the unix build constraint; on other systems, group_other.go provides two functions that do nothing, and only the go command is killed. On Windows the equivalent is a Job Object, which the companion code doesn’t use.
  • A process can leave the group. One that starts a new session with setsid is no longer in the group, so the kill misses it. An operating system sandbox catches that: bubblewrap, used on Linux at the next rung, puts the run in a process namespace of its own and ends it with its parent.
  • The plain runner doesn’t do it. GoTest, above, uses the default Cancel and a five-second WaitDelay. The triage tools use it too when the defenses are off, so there a test’s children can outlive a timeout.

Output the model can read

What a command prints becomes a tool result, which goes into the conversation and is sent again with every later request. So the output has to arrive in a readable order, be bounded in size, and say when it was cut.

One writer for both streams. The runner above assigns the same *capped value to Stdout and Stderr. When the two are the same writer and can be compared with ==, os/exec gives the child a single pipe for both, so error lines and normal output stay in the order the process wrote them, and only one goroutine calls Write at a time. cmd.CombinedOutput does the same with a bytes.Buffer, with no limit, so a test that prints in a loop can fill memory.

A cap applied while reading. capped keeps the first bytes it is given and drops the rest:

ch17/safety/sandbox.go
// capped keeps the first max bytes written to it and drops the rest, so
// a test that prints forever can't fill memory.
type capped struct {
	mu   sync.Mutex
	buf  bytes.Buffer
	max  int
	over bool
}

func (c *capped) Write(p []byte) (int, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if room := c.max - c.buf.Len(); room < len(p) {
		c.over = true
		c.buf.Write(p[:max(room, 0)])
		return len(p), nil
	}
	n, err := c.buf.Write(p)
	if err != nil {
		return n, fmt.Errorf("capture output: %w", err)
	}
	return n, nil
}

Write reports the whole write as taken even when it keeps none of it. A short count would end the copy from the pipe with an error, os/exec would close its end of the pipe, and the command would fail on its next write instead of finishing. This cap, 1 MiB by default, protects the harness’s memory. When it is reached, the run’s error says so, with ErrOutputCapped.

A smaller cap for the model, with a note. run_tests sends the model far less: its report keeps the output of failing tests, build errors, and each package’s result line, drops the output of passing tests, timings, and the temporary directory’s path, and then cuts the text to 8 KiB:

ch17/agent/gotest.go
// limit cuts s to at most n bytes, saying how much was cut.
func limit(s string, n int) string {
	if len(s) <= n {
		return s
	}
	for n > 0 && !utf8.RuneStart(s[n]) {
		n-- // don't cut a character in half
	}
	return s[:n] + fmt.Sprintf("\n[%d more bytes not shown]", len(s)-n)
}

A cut result that says how much is missing tells the model the output was long, so it doesn’t treat a partial result as the whole story. The file tools follow the same rule: code_read shows at most 200 lines and ends with the line number to ask for next, as designing tools describes.

A non-zero exit is a result. go test exits with status 1 when a test fails, and a failing test is exactly what the model is there to see. Here is the rest of GoTest, after Run:

ch17/agent/gotest.go
	if ctx.Err() != nil {
		err := fmt.Errorf("go test: %w", ctx.Err())
		if cause := context.Cause(ctx); !errors.Is(err, cause) {
			err = fmt.Errorf("%w: %w", err, cause)
		}
		return "", err
	}
	_, exited := errors.AsType[*exec.ExitError](err)
	if err != nil && !exited {
		return "", fmt.Errorf("run_tests: %w", err)
	}
	if err != nil && stdout.Len() == 0 {
		return "", fmt.Errorf("run_tests: %s",
			strings.TrimSpace(stderr.String()))
	}
	return report(&stdout, stderr.String(), dir), nil
}

The order of the checks matters:

  1. The context first. A command killed by Cancel returns an *exec.ExitError too, whose message is just “signal: killed”. Checked any later, a timeout would look like a test failure.
  2. Then the exit status. errors.AsType[*exec.ExitError] tells “the command ran and exited non-zero” apart from “the command couldn’t run”, such as a missing go binary, for which errors.Is(err, exec.ErrNotFound) is true. If you need the status itself, ExitCode() on the *exec.ExitError returns it.
  3. A failure with nothing on standard output. That means go test refused to start, for example over a broken go.mod; its standard error becomes the error text.
  4. Everything else is the report, returned with a nil error, whatever the exit status was.

The hardened runner makes the same choice in fewer lines:

ch17/safety/sandbox.go
// runError turns how the command ended into the run's error. A failing
// test is a result, not an error.
func runError(ctx context.Context, err error, over bool) error {
	switch {
	case ctx.Err() != nil:
		return fmt.Errorf("run tests: %w", context.Cause(ctx))
	case over:
		return fmt.Errorf("run tests: %w", ErrOutputCapped)
	}
	var exit *exec.ExitError
	switch {
	case err == nil, errors.As(err, &exit):
		return nil
	case errors.Is(err, exec.ErrWaitDelay):
		return nil // a test left a child holding the output; the group kill ended it
	}
	return fmt.Errorf("run tests: %w", err)
}

It adds one case: exec.ErrWaitDelay is what Run returns when the command exited successfully but something it started kept the output open until WaitDelay expired. killGroup has already ended that process, so the run counts as finished.

The errors these functions do return still don’t stop the agent. A tool’s error goes back to the model as the tool’s result, marked as an error, unless it wraps ErrToolFatal; the errors page shows that split, and why the agent stopped lists what does end a run. A test run that timed out is something the model can act on, by running fewer tests or fixing a hang.

The environment it runs in

cmd.Env decides what the child’s environment holds:

  • nil gives the child a copy of your whole environment, OPENROUTER_API_KEY and any other secret in it included.
  • A non-nil slice is the child’s entire environment. An empty, non-nil slice really is empty: env run that way prints nothing.
  • A repeated key takes its last value, so appending CGO_ENABLED=1 after CGO_ENABLED=0 turns cgo on.

The code go test runs is code the model wrote or changed, so it gets an environment built from nothing:

ch17/agent/gotest.go
// testEnv is the environment go test runs with: only what the go
// command needs, so the code under test can't read your API key, and
// settings that keep it offline and away from your other projects.
func testEnv() []string {
	env := []string{
		"GOWORK=off", "GOPROXY=off", "GOFLAGS=", "GOENV=off",
		"GOTOOLCHAIN=local", "CGO_ENABLED=0",
	}
	for _, k := range []string{"PATH", "HOME", "TMPDIR", "GOCACHE",
		"GOPATH", "GOMODCACHE", "XDG_CACHE_HOME", // Linux cache
		"USERPROFILE", "LOCALAPPDATA", "TMP", "TEMP", // Windows
		"GOROOT"} {
		if v, ok := os.LookupEnv(k); ok {
			env = append(env, k+"="+v)
		}
	}
	return env
}

The first six entries keep go test offline and independent of your own Go setup:

Setting What it stops
GOWORK=off A go.work file in a parent directory pulling other modules into the build
GOPROXY=off Downloading modules; a module not already in the cache is an error
GOFLAGS= Flags you set for all your go commands, such as -mod=mod, applying to this one
GOENV=off Settings saved with go env -w in your Go configuration file
GOTOOLCHAIN=local Downloading a newer Go when go.mod asks for one; the installed go reports “go.mod requires go >= …” instead
CGO_ENABLED=0 Calling a C compiler, unless the run asked for -race

The loop after them passes through only the variables the go command needs to find itself and its caches, each copied only if it is set. The run_tests tests include a case named “no API key”, which sets OPENROUTER_API_KEY and runs a test that fails if it can see the key.

The hardened runner goes further and inherits nothing but PATH:

ch17/safety/sandbox.go
// env is the whole environment the test run gets. Nothing is inherited
// but PATH, so the agent's API key, a cloud token, or an SSH agent
// socket in the user's environment never reaches test code.
func (t TestRunner) env() []string {
	s := t.Scratch
	return []string{
		"PATH=" + os.Getenv("PATH"),
		"HOME=" + filepath.Join(s, "home"),
		"GOCACHE=" + filepath.Join(s, "cache"),
		"GOMODCACHE=" + filepath.Join(s, "mod"),
		"GOPATH=" + filepath.Join(s, "gopath"),
		"GOTMPDIR=" + filepath.Join(s, "tmp"),
		"TMPDIR=" + filepath.Join(s, "tmp"),
		"GOPROXY=off", "GOTOOLCHAIN=local", "GOENV=off", "GOWORK=off",
		"GOFLAGS=", "CGO_ENABLED=0", "GIT_CONFIG_NOSYSTEM=1",
	}
}

HOME, the build cache, the module cache, and the temporary directory all point into a private scratch directory made for the run. A program that finds your files through $HOME, such as git reading its configuration, sees an empty home instead. A program that names /Users/you/.ssh directly still reaches it; stopping that is the operating system sandbox’s job.

The same rule applies to every child the harness starts. The MCP client above copies cmd.Env into a new slice, because a nil Env would hand the server your whole environment. The triage program also removes its key variables from its own environment at startup, so a command started anywhere with a nil Env can’t inherit them. On macOS that still leaves a gap: a process can read the starting environment of another process run by the same user, which is why the prompt injection guide keeps keys in files only you can read.

Files inside one directory: os.Root

A file tool takes a path from the model. Joining that path to the workspace with filepath.Join and calling os.Open trusts it completely: ../../.ssh/id_ed25519 leads out, and so does a symbolic link inside the workspace that points elsewhere. Checking the text of the path catches the first and can’t see the second.

os.OpenRoot(dir) opens the directory, on most systems as a file descriptor, so the root keeps referring to the same directory even if it is renamed while open. The methods mirror the os functions. Go 1.24 has Open, OpenFile, Create, Stat, Lstat, Mkdir, Remove, OpenRoot for a subdirectory, FS, Name, and Close. Go 1.25 added ReadFile, WriteFile, MkdirAll, RemoveAll, Rename, Link, Symlink, Readlink, Chmod, Chown, Lchown, and Chtimes. os.OpenInRoot(dir, name) opens one file in one call. A Root is safe to use from several goroutines.

A test program on macOS opened a workspace directory, ws, as a root, put secret.txt beside it, and tried these names:

Name given to root.Open Result
store/a.go Opened
store/../store/a.go Opened: .. that stays inside is allowed
../secret.txt, store/../../secret.txt Refused
/etc/passwd, or any absolute path, even one inside the workspace Refused
A link to ../secret.txt Refused
A link with an absolute target, even a target inside the workspace Refused
A link to store/a.go Opened
A link to ../ws/store/a.go, which leaves the workspace and comes back Refused
A name that doesn’t exist An error that matches fs.ErrNotExist

Writes behave the same: root.Create("../new.txt") and root.WriteFile through an escaping link both failed, and nothing appeared outside. Three details from the same run matter when you write a tool:

  • The error is an *fs.PathError whose message ends in “path escapes from parent”. The error inside it is unexported in package os, so there is no sentinel to check with errors.Is; check fs.ErrNotExist for a missing file and refuse the call on any other error.
  • root.Lstat on an escaping link succeeds, because it describes the link itself, not what the link points to.
  • root.FS() is stricter than the root. It returns an fs.FS, which accepts only names that pass fs.ValidPath, so store/../store/a.go fails there with fs.ErrInvalid even though root.Open accepts it.

The companion code first uses a root to write the project files that run_tests copies into a temporary directory:

ch17/agent/memfs.go
// WriteDir writes the files into dir. It opens dir as an os.Root, so a
// file name such as "../x" can't write outside it.
func (fs FS) WriteDir(dir string) error {
	root, err := os.OpenRoot(dir)
	if err != nil {
		return fmt.Errorf("write files: %w", err)
	}
	defer root.Close()
	for _, name := range fs.List() {
		err := root.WriteFile(name, []byte(fs[name]), 0o644)
		if err != nil {
			return fmt.Errorf("write files: %w", err)
		}
	}
	return nil
}

TestWriteDirStaysInDir gives it a file named ../escape.go and checks that the write fails.

The triage agent opens its workspace once, when its tools are set up, and keeps the root for the whole run:

ch17/triage/env.go
// NewEnv opens the workspace for the tools.
func NewEnv(dir, original string, reports []Report,
	issues *tracker.Store) (*Env, error) {
	root, err := os.OpenRoot(dir)
	if err != nil {
		return nil, fmt.Errorf("open workspace: %w", err)
	}
	return &Env{Reports: reports, Issues: issues, Dir: dir,
		Original: original, Target: Remind, root: root, read: map[string]bool{},
		created: map[string]bool{}, notes: map[string][]string{},
		noteKeys: map[string]bool{}}, nil
}

Every file tool goes through it: code_read and code_patch with root.ReadFile, root.WriteFile, and root.Stat, and code_list and code_search walk root.FS() with fs.WalkDir. A text check on the path runs first, because its refusal can tell the model what a correct path looks like:

ch17/triage/codetools.go
// clean checks a path from the model and returns it in the form the
// workspace uses: relative to the module root, with slashes.
func clean(p string) (string, error) {
	p = path.Clean(strings.TrimPrefix(p, "./"))
	if path.IsAbs(p) || p == ".." || strings.HasPrefix(p, "../") {
		return "", fmt.Errorf("%q is outside the workspace; "+
			"paths are relative to the module root, such as store/store.go", p)
	}
	return p, nil
}

The root is the second check, and the one that catches links. code_read turns any read error, whether a missing file or a refusal, into a message with a next step:

ch17/triage/codetools.go
func(_ context.Context, a readArgs) (string, error) {
	p, err := clean(a.Path)
	if err != nil {
		return "", err
	}
	data, err := e.root.ReadFile(p)
	if err != nil {
		return "", fmt.Errorf("no file %q; use code_list or "+
			"code_search to find it", p)
	}

The approval gate, which hashes each file a call would change so that a person’s yes covers exactly what they saw, reads through a root too, and uses root.Lstat before opening anything:

ch17/safety/approve.go
func contentHash(dir, name string) (string, error) {
	if resource(name) {
		return "a resource", nil
	}
	root, err := os.OpenRoot(dir)
	if err != nil {
		return "", fmt.Errorf("open the workspace: %w", err)
	}
	defer root.Close()
	info, err := root.Lstat(name)
	switch {
	case errors.Is(err, os.ErrNotExist):
		return "absent", nil // a file the call would create
	case err != nil:
		return "", fmt.Errorf("stat: %w", err)
	case !info.Mode().IsRegular():
		return "not a regular file: " + info.Mode().Type().String(), nil
	case info.Size() > maxTarget:
		return "", fmt.Errorf("over %d bytes", maxTarget)
	}
	f, err := root.Open(name)
	if err != nil {
		return "", fmt.Errorf("open: %w", err)
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, io.LimitReader(f, maxTarget+1)); err != nil {
		return "", fmt.Errorf("read: %w", err)
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

Checking the type first means a named pipe planted in the workspace can’t make the gate wait forever on a read, and io.LimitReader bounds the read.

A root allows links that stay inside it, and that leaves one gap a write tool may care about. A link from store/a to ../auth stays inside the workspace, so os.Root follows it, and a write to store/a/auth.go changes a file in auth/. If some paths in the workspace are off limits, check them separately: the protected-path check refuses any write whose path has a link or a .. anywhere along it.

What these don’t cover

os.Root limits the harness’s own file calls, nothing more. The command run_tests starts is a separate program, running as your user:

  • It can open any file you can, by its absolute path, and connect to any address your machine can reach. An empty environment and a private HOME don’t change that.
  • os.Root guards paths, not files. Its documentation says it doesn’t stop crossing into other file systems, Linux bind mounts, /proc special files, or Unix device files. And a hard link is the same file under a second name: in a test, a hard link inside the root to a file outside it let root.ReadFile read that file and root.WriteFile change it. Test code runs as you, so it can make such a link.
  • A process group can be left, as above.

Everything on this page stops accidents: a test that hangs, a test that prints forever, a model that guesses a path with .. in it, a key sitting in the environment. None of it stops code written to do harm. When the agent runs code shaped by text from strangers, such as a crash report, it needs an operating system sandbox, a check on everything that leaves the run, and a person’s approval for the actions that matter; the prompt injection guide lays out that ladder and the approval guide the gate.

Mistakes to avoid

  • A nil cmd.Env. The child inherits everything, keys included. append(os.Environ(), ...) has the same problem.
  • No WaitDelay when the output goes to a Go writer. A grandchild that keeps the pipe open keeps Run waiting with it.
  • Killing only the parent. On Unix, start the command in its own process group and kill the group, on cancel and after a normal exit.
  • Reading a killed command’s error as its result. “signal: killed” is an *exec.ExitError; check the context before the exit status.
  • A non-zero exit as a Go error that ends the run. For a test runner, a failing test is the result the model needs.
  • CombinedOutput on a command you don’t control. It keeps everything; use a writer with a cap, and tell the model when its output was cut.
  • filepath.Join(workspace, path) with a path from the model. Text checks miss links; open the workspace with os.OpenRoot and use its methods.
  • Expecting os.Root to confine the programs you run. It only governs calls made through it.

Try it

From the code directory of a clone of the companion repository; none of these needs an API key:

terminal
$ go test ./ch17/agent -run 'TestRunTests$|TestWriteDirStaysInDir|TestReportOrderAndPaths' -v
$ go test ./ch17/triage -run TestCodeTools -v
$ go test ./ch17/safety -run TestRunner -v

The first runs run_tests against failing, passing, and broken projects and the “no API key” case, and checks that WriteDir refuses ../escape.go. The second includes a code_read of ../../etc/passwd. The third, which runs only on Unix, covers the hardened runner: the environment, the group kill on timeout, the output cap, and a child left holding the output. go test ./ch17/agent -run TestRunTestsLimits -v checks the timeouts and the 8 KiB cut; it takes about a minute and a half, because it waits for go test’s own 90-second timeout to stop a hanging test.

  1. In testEnv, add "OPENROUTER_API_KEY" to the list of variables passed through, and run TestRunTests again: the “no API key” case fails with “key visible”.
  2. In TestRunner.Run, comment out group(cmd) and run TestRunnerKillsGroup and TestRunnerLinger: both report that the child outlived the run.
  3. In the triage workspace that TestCodeTools sets up, make a symbolic link that points outside it, such as to /etc/hosts, and call code_read on the link’s name: the text check lets it through, and the root refuses it.