[engine] Support static env var pass-throughs

Allow users to selectively poke holes in the subprocess sandbox by
specifying `pass_through_env` in shac.textproto. `pass_through_env` is a
list of environment variables that should be passed through the sandbox,
along with bits indicating whether the variable's value represents a
file that should also be mounted and, if so, whether it should be
writeable by subprocesses.

We can immediately use this feature to grant Go checks in this
repository access to $HOME so they can share the same go cache with the
rest of the system (ideally we could use $GOCACHE, but it's not
guaranteed to be set, and if it's not set it's inferred from $HOME).
Same for tests that run `go run` since they can make use of the cache
instead of doing a full recompile on every test run; as a result, the
runtime of `go test ./internal/engine` went from ~4.2 seconds to ~1.2
seconds on my workstation.

This is a medium-term workaround until we support declaring
pass-throughs in Starlark, which would allow things like running `go env
GOCACHE` to obtain and pass through just the $GOCACHE directory, while
omitting the rest of $HOME.

Change-Id: I0bcc9956c4c4e2e9cca292925c66f6aed6f07524
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/922772
Reviewed-by: Anthony Fandrianto <atyfto@google.com>
Fuchsia-Auto-Submit: Oliver Newman <olivernewman@google.com>
Commit-Queue: Oliver Newman <olivernewman@google.com>
diff --git a/README.md b/README.md
index 335e8f1..c009e65 100644
--- a/README.md
+++ b/README.md
@@ -143,7 +143,6 @@
                     # End column is exclusive.
                     end_col = len(line) + 1,
                     message = "Delete trailing whitespace.",
-                    replacements = [""],
                 )
 
 shac.register_check(no_trailing_whitespace)
@@ -276,6 +275,8 @@
 - [ ] Built-in formatting of Starlark files
 - [ ] Configurable "pass-throughs" - non-default environment variables and
       mounts that can optionally be passed through to the sandbox
+  - [x] Passed-through environment variables statically declared in
+     shac.textproto
 - [ ] Add `glob` arguments to `ctx.scm.{all,affected}_files()` functions for
       easier filtering
 - [ ] Filesystem sandboxing on MacOS
diff --git a/checks/common.star b/checks/common.star
index a991f39..58758c0 100644
--- a/checks/common.star
+++ b/checks/common.star
@@ -14,21 +14,27 @@
 
 def go_install(ctx, pkg, version):
     """Runs `go install`."""
-    tool_name = pkg.split("/")[-1]
-    env = go_env(ctx, tool_name)
+
+    env = go_env()
+
+    # TODO(olivernewman): Implement proper cache directories for shac instead of
+    # creating a `.tools` directory, which requires making the root directory
+    # writable.
+    env["GOBIN"] = ctx.scm.root + "/.tools/gobin"
+
+    # TODO(olivernewman): Stop using a separate GOPATH for each tool, and instead
+    # install the tools sequentially. Multiple concurrent `go install` runs on the
+    # same GOPATH results in race conditions.
     ctx.os.exec(
         ["go", "install", "%s@%s" % (pkg, version)],
         allow_network = True,
         env = env,
     ).wait()
 
+    tool_name = pkg.split("/")[-1]
     return "%s/%s" % (env["GOBIN"], tool_name)
 
-def go_env(ctx, key):
-    # TODO(olivernewman): Stop using a separate GOPATH for each tool, and instead
-    # install the tools sequentially. Multiple concurrent `go install` runs on the
-    # same GOPATH results in race conditions.
-    gopath = "%s/.tools/gopath/%s" % (ctx.scm.root, key)
+def go_env():
     return {
         # Disable cgo as it's not necessary and not all development platforms have
         # the necessary headers.
@@ -38,11 +44,6 @@
             # to fail on some machines.
             "-buildvcs=false",
         ]),
-        "GOPATH": gopath,
-        "GOBIN": "%s/bin" % gopath,
-        # Cache within the directory to avoid writing to $HOME/.cache.
-        # TODO(olivernewman): Implement named caches.
-        "GOCACHE": "%s/.tools/gocache" % ctx.scm.root,
         # TODO(olivernewman): The default gopackagesdriver is broken within an
         # nsjail.
         "GOPACKAGESDRIVER": "off",
diff --git a/checks/go.star b/checks/go.star
index 5cf216b..6e50c28 100644
--- a/checks/go.star
+++ b/checks/go.star
@@ -64,7 +64,7 @@
     res = ctx.os.exec(
         [exe, "-fmt=json", "-quiet", "-exclude=%s" % ",".join(exclude), "-exclude-dir=.tools", "./..."],
         ok_retcodes = (0, 1),
-        env = go_env(ctx, "gosec"),
+        env = go_env(),
     ).wait()
     if not res.retcode:
         return
@@ -112,7 +112,7 @@
     exe = go_install(ctx, "github.com/gordonklaus/ineffassign", version)
     res = ctx.os.exec(
         [exe, "./..."],
-        env = go_env(ctx, "ineffassign"),
+        env = go_env(),
         # ineffassign's README claims that it emits a retcode of 1 if it returns any
         # findings, but it actually emits a retcode of 3.
         # https://github.com/gordonklaus/ineffassign/blob/4cc7213b9bc8b868b2990c372f6fa057fa88b91c/ineffassign.go#L70
@@ -143,8 +143,7 @@
       will be rolled from time to time.
     """
     exe = go_install(ctx, "honnef.co/go/tools/cmd/staticcheck", version)
-    env = go_env(ctx, "staticcheck")
-    env["STATICCHECK_CACHE"] = env["GOCACHE"]
+    env = go_env()
     res = ctx.os.exec(
         [exe, "-f=json", "./..."],
         ok_retcodes = [0, 1],
@@ -193,7 +192,7 @@
             "-json",
             "./...",
         ],
-        env = go_env(ctx, "shadow"),
+        env = go_env(),
     ).wait()
 
     # Example output:
@@ -242,7 +241,7 @@
             "-json",
             "./...",
         ],
-        env = go_env(ctx, "no_fork_without_lock"),
+        env = go_env(),
     ).wait().stdout)
 
     # Skip the "execsupport" package since it contains the wrappers around Run()
diff --git a/internal/engine/run.go b/internal/engine/run.go
index fa73f5b..bd47fd8 100644
--- a/internal/engine/run.go
+++ b/internal/engine/run.go
@@ -218,10 +218,13 @@
 	if config == "" {
 		config = "shac.textproto"
 	}
-	p := filepath.Join(root, config)
+	absConfig := config
+	if !filepath.IsAbs(absConfig) {
+		absConfig = filepath.Join(root, absConfig)
+	}
 	var b []byte
 	doc := Document{}
-	if b, err = os.ReadFile(p); err == nil {
+	if b, err = os.ReadFile(absConfig); err == nil {
 		// First parse the config file ignoring unknown fields and check only
 		// min_shac_version, so users get an "unsupported version" error if they
 		// set fields that are only available in a later version of shac (as
@@ -301,6 +304,9 @@
 		packages: packages,
 	}
 
+	if entryPoint == "ctx-os-exec-10Mib-exceed.star" {
+		fmt.Println(doc)
+	}
 	newState := func(scm scmCheckout, subdir string, idx int) *shacState {
 		if subdir != "" {
 			normalized := subdir + "/"
@@ -311,18 +317,19 @@
 			scm = &subdirSCM{s: scm, subdir: normalized}
 		}
 		return &shacState{
-			allowNetwork: doc.AllowNetwork,
-			env:          &env,
-			filter:       o.Filter,
-			entryPoint:   entryPoint,
-			r:            o.Report,
-			root:         root,
-			sandbox:      sb,
-			scm:          scm,
-			subdir:       subdir,
-			tmpdir:       filepath.Join(tmpdir, strconv.Itoa(idx)),
-			writableRoot: doc.WritableRoot,
-			vars:         vars,
+			allowNetwork:   doc.AllowNetwork,
+			env:            &env,
+			filter:         o.Filter,
+			entryPoint:     entryPoint,
+			r:              o.Report,
+			root:           root,
+			sandbox:        sb,
+			scm:            scm,
+			subdir:         subdir,
+			tmpdir:         filepath.Join(tmpdir, strconv.Itoa(idx)),
+			writableRoot:   doc.WritableRoot,
+			vars:           vars,
+			passthroughEnv: doc.PassthroughEnv,
 		}
 	}
 	var shacStates []*shacState
@@ -542,7 +549,8 @@
 	// mutated. They run checks and emit results (results and comments).
 	checks []registeredCheck
 	// filter controls which checks run. If nil, all checks will run.
-	filter CheckFilter
+	filter         CheckFilter
+	passthroughEnv []*PassthroughEnv
 
 	// Set when fail() is called. This happens only during the first phase, thus
 	// no mutex is needed.
diff --git a/internal/engine/run_test.go b/internal/engine/run_test.go
index 15146ee..a543d53 100644
--- a/internal/engine/run_test.go
+++ b/internal/engine/run_test.go
@@ -16,6 +16,7 @@
 
 import (
 	"context"
+	"crypto/md5"
 	"errors"
 	"fmt"
 	"io"
@@ -551,6 +552,89 @@
 	}
 }
 
+func TestRun_PassthroughEnv(t *testing.T) {
+	hash := fmt.Sprintf("%x", md5.Sum([]byte(t.Name())))
+
+	varPrefix := "TEST_" + hash + "_"
+	nonFileVarname := varPrefix + "VAR"
+	readOnlyDirVarname := varPrefix + "RO_DIR"
+	writeableDirVarname := varPrefix + "WRITEABLE_DIR"
+	env := map[string]string{
+		nonFileVarname:      "this is not a file",
+		readOnlyDirVarname:  filepath.Join(t.TempDir(), "readonly"),
+		writeableDirVarname: filepath.Join(t.TempDir(), "writeable"),
+	}
+	mkdirAll(t, env[readOnlyDirVarname])
+	mkdirAll(t, env[writeableDirVarname])
+
+	for k, v := range env {
+		t.Setenv(k, v)
+	}
+
+	config := &Document{
+		PassthroughEnv: []*PassthroughEnv{
+			{
+				Name: nonFileVarname,
+			},
+			{
+				Name:   readOnlyDirVarname,
+				IsPath: true,
+			},
+			{
+				Name:      writeableDirVarname,
+				IsPath:    true,
+				Writeable: true,
+			},
+			{
+				// Additionally give access to HOME, which contains the
+				// Go cache, so checks that run `go run` can use cached
+				// artifacts.
+				Name:      "HOME",
+				IsPath:    true,
+				Writeable: true,
+			},
+		},
+		Vars: []*Var{{Name: "VAR_PREFIX"}},
+	}
+	root := t.TempDir()
+	writeFile(t, root, "shac.textproto", prototext.Format(config))
+
+	main := "ctx-os-exec-passthrough_env.star"
+	copyFile(t, root, filepath.Join("testdata", main))
+	copyFile(t, root, filepath.Join("testdata", "ctx-os-exec-passthrough_env.go"))
+
+	r := reportPrint{reportNoPrint: reportNoPrint{t: t}}
+	o := Options{
+		Report:     &r,
+		Dir:        root,
+		EntryPoint: main,
+		Vars:       map[string]string{"VAR_PREFIX": varPrefix},
+	}
+
+	const filesystemSandboxed = runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
+
+	wantLines := []string{
+		"[//ctx-os-exec-passthrough_env.star:25] non-file env var: this is not a file",
+		"read-only dir env var: " + env[readOnlyDirVarname],
+		"writeable dir env var: " + env[writeableDirVarname],
+		"able to write to writeable dir",
+	}
+	if filesystemSandboxed {
+		wantLines = append(wantLines, fmt.Sprintf(
+			"error writing to read-only dir: open %s: read-only file system",
+			filepath.Join(env[readOnlyDirVarname], "foo.txt")))
+	} else {
+		wantLines = append(wantLines, "able to write to read-only dir")
+	}
+
+	if err := Run(context.Background(), &o); err != nil {
+		t.Fatal(err)
+	}
+	if diff := cmp.Diff(strings.Join(wantLines, "\n")+"\n", r.b.String()); diff != "" {
+		t.Fatalf("mismatch (-want +got):\n%s", diff)
+	}
+}
+
 func TestRun_SCM_Raw(t *testing.T) {
 	t.Parallel()
 	root := t.TempDir()
diff --git a/internal/engine/runtime_ctx_os.go b/internal/engine/runtime_ctx_os.go
index 115335e..977cf85 100644
--- a/internal/engine/runtime_ctx_os.go
+++ b/internal/engine/runtime_ctx_os.go
@@ -237,6 +237,22 @@
 			env["PATH"],
 		}, string(os.PathListSeparator))
 	}
+
+	var passthroughMounts []sandbox.Mount
+	for _, pte := range s.passthroughEnv {
+		val, ok := os.LookupEnv(pte.Name)
+		if !ok {
+			continue
+		}
+		env[pte.Name] = val
+		if pte.IsPath {
+			passthroughMounts = append(passthroughMounts, sandbox.Mount{
+				Path:     val,
+				Writable: pte.Writeable,
+			})
+		}
+	}
+
 	for _, item := range argenv.Items() {
 		k, ok := item[0].(starlark.String)
 		if !ok {
@@ -342,6 +358,7 @@
 			// this executable.
 			{Path: filepath.Dir(tempDir), Writable: true},
 		}
+		config.Mounts = append(config.Mounts, passthroughMounts...)
 
 		// TODO(olivernewman): This is necessary because checks for shac itself
 		// assume Go is pre-installed. Switch to a hermetic Go installation that
diff --git a/internal/engine/shac.pb.go b/internal/engine/shac.pb.go
index 918e914..8cb75d4 100644
--- a/internal/engine/shac.pb.go
+++ b/internal/engine/shac.pb.go
@@ -60,6 +60,8 @@
 	// are implemented.
 	WritableRoot bool   `protobuf:"varint,7,opt,name=writable_root,json=writableRoot,proto3" json:"writable_root,omitempty"`
 	Vars         []*Var `protobuf:"bytes,8,rep,name=vars,proto3" json:"vars,omitempty"`
+	// Environment variables to pass through the sandbox.
+	PassthroughEnv []*PassthroughEnv `protobuf:"bytes,9,rep,name=passthrough_env,json=passthroughEnv,proto3" json:"passthrough_env,omitempty"`
 }
 
 func (x *Document) Reset() {
@@ -150,6 +152,13 @@
 	return nil
 }
 
+func (x *Document) GetPassthroughEnv() []*PassthroughEnv {
+	if x != nil {
+		return x.PassthroughEnv
+	}
+	return nil
+}
+
 // Var specifies a variable that may be passed into checks at runtime by the
 // --var flag and accessed via `ctx.vars.get(name)`.
 //
@@ -224,6 +233,75 @@
 	return ""
 }
 
+// PassthroughEnv specifies an environment variable that should be passed
+// through into the sandbox.
+type PassthroughEnv struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the environment variable, e.g. "FOO".
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Whether the environment variable's value is a file path that sandboxed
+	// processes should be granted access to.
+	IsPath bool `protobuf:"varint,2,opt,name=is_path,json=isPath,proto3" json:"is_path,omitempty"`
+	// If is_path is true, whether to mount the file/directory as writeable.
+	Writeable bool `protobuf:"varint,3,opt,name=writeable,proto3" json:"writeable,omitempty"`
+}
+
+func (x *PassthroughEnv) Reset() {
+	*x = PassthroughEnv{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_shac_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *PassthroughEnv) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PassthroughEnv) ProtoMessage() {}
+
+func (x *PassthroughEnv) ProtoReflect() protoreflect.Message {
+	mi := &file_shac_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use PassthroughEnv.ProtoReflect.Descriptor instead.
+func (*PassthroughEnv) Descriptor() ([]byte, []int) {
+	return file_shac_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *PassthroughEnv) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *PassthroughEnv) GetIsPath() bool {
+	if x != nil {
+		return x.IsPath
+	}
+	return false
+}
+
+func (x *PassthroughEnv) GetWriteable() bool {
+	if x != nil {
+		return x.Writeable
+	}
+	return false
+}
+
 // Requirements lists all the external dependencies, both direct and transitive
 // (indirect).
 type Requirements struct {
@@ -240,7 +318,7 @@
 func (x *Requirements) Reset() {
 	*x = Requirements{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[2]
+		mi := &file_shac_proto_msgTypes[3]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -253,7 +331,7 @@
 func (*Requirements) ProtoMessage() {}
 
 func (x *Requirements) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[2]
+	mi := &file_shac_proto_msgTypes[3]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -266,7 +344,7 @@
 
 // Deprecated: Use Requirements.ProtoReflect.Descriptor instead.
 func (*Requirements) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{2}
+	return file_shac_proto_rawDescGZIP(), []int{3}
 }
 
 func (x *Requirements) GetDirect() []*Dependency {
@@ -303,7 +381,7 @@
 func (x *Dependency) Reset() {
 	*x = Dependency{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[3]
+		mi := &file_shac_proto_msgTypes[4]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -316,7 +394,7 @@
 func (*Dependency) ProtoMessage() {}
 
 func (x *Dependency) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[3]
+	mi := &file_shac_proto_msgTypes[4]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -329,7 +407,7 @@
 
 // Deprecated: Use Dependency.ProtoReflect.Descriptor instead.
 func (*Dependency) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{3}
+	return file_shac_proto_rawDescGZIP(), []int{4}
 }
 
 func (x *Dependency) GetUrl() string {
@@ -365,7 +443,7 @@
 func (x *Sum) Reset() {
 	*x = Sum{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[4]
+		mi := &file_shac_proto_msgTypes[5]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -378,7 +456,7 @@
 func (*Sum) ProtoMessage() {}
 
 func (x *Sum) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[4]
+	mi := &file_shac_proto_msgTypes[5]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -391,7 +469,7 @@
 
 // Deprecated: Use Sum.ProtoReflect.Descriptor instead.
 func (*Sum) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{4}
+	return file_shac_proto_rawDescGZIP(), []int{5}
 }
 
 func (x *Sum) GetKnown() []*Known {
@@ -414,7 +492,7 @@
 func (x *Known) Reset() {
 	*x = Known{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[5]
+		mi := &file_shac_proto_msgTypes[6]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -427,7 +505,7 @@
 func (*Known) ProtoMessage() {}
 
 func (x *Known) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[5]
+	mi := &file_shac_proto_msgTypes[6]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -440,7 +518,7 @@
 
 // Deprecated: Use Known.ProtoReflect.Descriptor instead.
 func (*Known) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{5}
+	return file_shac_proto_rawDescGZIP(), []int{6}
 }
 
 func (x *Known) GetUrl() string {
@@ -473,7 +551,7 @@
 func (x *VersionDigest) Reset() {
 	*x = VersionDigest{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[6]
+		mi := &file_shac_proto_msgTypes[7]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -486,7 +564,7 @@
 func (*VersionDigest) ProtoMessage() {}
 
 func (x *VersionDigest) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[6]
+	mi := &file_shac_proto_msgTypes[7]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -499,7 +577,7 @@
 
 // Deprecated: Use VersionDigest.ProtoReflect.Descriptor instead.
 func (*VersionDigest) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{6}
+	return file_shac_proto_rawDescGZIP(), []int{7}
 }
 
 func (x *VersionDigest) GetVersion() string {
@@ -520,7 +598,7 @@
 
 var file_shac_proto_rawDesc = []byte{
 	0x0a, 0x0a, 0x73, 0x68, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, 0x65, 0x6e,
-	0x67, 0x69, 0x6e, 0x65, 0x22, 0xb1, 0x02, 0x0a, 0x08, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e,
+	0x67, 0x69, 0x6e, 0x65, 0x22, 0xf2, 0x02, 0x0a, 0x08, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e,
 	0x74, 0x12, 0x28, 0x0a, 0x10, 0x6d, 0x69, 0x6e, 0x5f, 0x73, 0x68, 0x61, 0x63, 0x5f, 0x76, 0x65,
 	0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6d, 0x69, 0x6e,
 	0x53, 0x68, 0x61, 0x63, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x61,
@@ -539,39 +617,49 @@
 	0x6f, 0x6f, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x77, 0x72, 0x69, 0x74, 0x61,
 	0x62, 0x6c, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x04, 0x76, 0x61, 0x72, 0x73, 0x18,
 	0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x56,
-	0x61, 0x72, 0x52, 0x04, 0x76, 0x61, 0x72, 0x73, 0x22, 0x55, 0x0a, 0x03, 0x56, 0x61, 0x72, 0x12,
-	0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
-	0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
-	0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
-	0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
-	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x22,
-	0x6a, 0x0a, 0x0c, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12,
-	0x2a, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
-	0x12, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65,
-	0x6e, 0x63, 0x79, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x12, 0x2e, 0x0a, 0x08, 0x69,
-	0x6e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e,
+	0x61, 0x72, 0x52, 0x04, 0x76, 0x61, 0x72, 0x73, 0x12, 0x3f, 0x0a, 0x0f, 0x70, 0x61, 0x73, 0x73,
+	0x74, 0x68, 0x72, 0x6f, 0x75, 0x67, 0x68, 0x5f, 0x65, 0x6e, 0x76, 0x18, 0x09, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x16, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x74,
+	0x68, 0x72, 0x6f, 0x75, 0x67, 0x68, 0x45, 0x6e, 0x76, 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, 0x74,
+	0x68, 0x72, 0x6f, 0x75, 0x67, 0x68, 0x45, 0x6e, 0x76, 0x22, 0x55, 0x0a, 0x03, 0x56, 0x61, 0x72,
+	0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74,
+	0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72,
+	0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c,
+	0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
+	0x22, 0x5b, 0x0a, 0x0e, 0x50, 0x61, 0x73, 0x73, 0x74, 0x68, 0x72, 0x6f, 0x75, 0x67, 0x68, 0x45,
+	0x6e, 0x76, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x70, 0x61, 0x74,
+	0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x50, 0x61, 0x74, 0x68, 0x12,
+	0x1c, 0x0a, 0x09, 0x77, 0x72, 0x69, 0x74, 0x65, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x09, 0x77, 0x72, 0x69, 0x74, 0x65, 0x61, 0x62, 0x6c, 0x65, 0x22, 0x6a, 0x0a,
+	0x0c, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2a, 0x0a,
+	0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e,
 	0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
-	0x79, 0x52, 0x08, 0x69, 0x6e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x4e, 0x0a, 0x0a, 0x44,
-	0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c,
-	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x61,
-	0x6c, 0x69, 0x61, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x61,
-	0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x2a, 0x0a, 0x03, 0x53,
-	0x75, 0x6d, 0x12, 0x23, 0x0a, 0x05, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x18, 0x01, 0x20, 0x03, 0x28,
-	0x0b, 0x32, 0x0d, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x4b, 0x6e, 0x6f, 0x77, 0x6e,
-	0x52, 0x05, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x22, 0x44, 0x0a, 0x05, 0x4b, 0x6e, 0x6f, 0x77, 0x6e,
-	0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75,
-	0x72, 0x6c, 0x12, 0x29, 0x0a, 0x04, 0x73, 0x65, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
-	0x32, 0x15, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
-	0x6e, 0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x52, 0x04, 0x73, 0x65, 0x65, 0x6e, 0x22, 0x41, 0x0a,
-	0x0d, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x12, 0x18,
-	0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x67, 0x65,
-	0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74,
-	0x42, 0x32, 0x5a, 0x30, 0x67, 0x6f, 0x2e, 0x66, 0x75, 0x63, 0x68, 0x73, 0x69, 0x61, 0x2e, 0x64,
-	0x65, 0x76, 0x2f, 0x73, 0x68, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f,
-	0x73, 0x68, 0x61, 0x63, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x65, 0x6e,
-	0x67, 0x69, 0x6e, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x79, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x12, 0x2e, 0x0a, 0x08, 0x69, 0x6e, 0x64,
+	0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x65, 0x6e,
+	0x67, 0x69, 0x6e, 0x65, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x52,
+	0x08, 0x69, 0x6e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x4e, 0x0a, 0x0a, 0x44, 0x65, 0x70,
+	0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x6c, 0x69,
+	0x61, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x12,
+	0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x2a, 0x0a, 0x03, 0x53, 0x75, 0x6d,
+	0x12, 0x23, 0x0a, 0x05, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x0d, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x4b, 0x6e, 0x6f, 0x77, 0x6e, 0x52, 0x05,
+	0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x22, 0x44, 0x0a, 0x05, 0x4b, 0x6e, 0x6f, 0x77, 0x6e, 0x12, 0x10,
+	0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c,
+	0x12, 0x29, 0x0a, 0x04, 0x73, 0x65, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15,
+	0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x44,
+	0x69, 0x67, 0x65, 0x73, 0x74, 0x52, 0x04, 0x73, 0x65, 0x65, 0x6e, 0x22, 0x41, 0x0a, 0x0d, 0x56,
+	0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07,
+	0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76,
+	0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x42, 0x32,
+	0x5a, 0x30, 0x67, 0x6f, 0x2e, 0x66, 0x75, 0x63, 0x68, 0x73, 0x69, 0x61, 0x2e, 0x64, 0x65, 0x76,
+	0x2f, 0x73, 0x68, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x73, 0x68,
+	0x61, 0x63, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x65, 0x6e, 0x67, 0x69,
+	0x6e, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -586,29 +674,31 @@
 	return file_shac_proto_rawDescData
 }
 
-var file_shac_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
+var file_shac_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
 var file_shac_proto_goTypes = []interface{}{
-	(*Document)(nil),      // 0: engine.Document
-	(*Var)(nil),           // 1: engine.Var
-	(*Requirements)(nil),  // 2: engine.Requirements
-	(*Dependency)(nil),    // 3: engine.Dependency
-	(*Sum)(nil),           // 4: engine.Sum
-	(*Known)(nil),         // 5: engine.Known
-	(*VersionDigest)(nil), // 6: engine.VersionDigest
+	(*Document)(nil),       // 0: engine.Document
+	(*Var)(nil),            // 1: engine.Var
+	(*PassthroughEnv)(nil), // 2: engine.PassthroughEnv
+	(*Requirements)(nil),   // 3: engine.Requirements
+	(*Dependency)(nil),     // 4: engine.Dependency
+	(*Sum)(nil),            // 5: engine.Sum
+	(*Known)(nil),          // 6: engine.Known
+	(*VersionDigest)(nil),  // 7: engine.VersionDigest
 }
 var file_shac_proto_depIdxs = []int32{
-	2, // 0: engine.Document.requirements:type_name -> engine.Requirements
-	4, // 1: engine.Document.sum:type_name -> engine.Sum
+	3, // 0: engine.Document.requirements:type_name -> engine.Requirements
+	5, // 1: engine.Document.sum:type_name -> engine.Sum
 	1, // 2: engine.Document.vars:type_name -> engine.Var
-	3, // 3: engine.Requirements.direct:type_name -> engine.Dependency
-	3, // 4: engine.Requirements.indirect:type_name -> engine.Dependency
-	5, // 5: engine.Sum.known:type_name -> engine.Known
-	6, // 6: engine.Known.seen:type_name -> engine.VersionDigest
-	7, // [7:7] is the sub-list for method output_type
-	7, // [7:7] is the sub-list for method input_type
-	7, // [7:7] is the sub-list for extension type_name
-	7, // [7:7] is the sub-list for extension extendee
-	0, // [0:7] is the sub-list for field type_name
+	2, // 3: engine.Document.passthrough_env:type_name -> engine.PassthroughEnv
+	4, // 4: engine.Requirements.direct:type_name -> engine.Dependency
+	4, // 5: engine.Requirements.indirect:type_name -> engine.Dependency
+	6, // 6: engine.Sum.known:type_name -> engine.Known
+	7, // 7: engine.Known.seen:type_name -> engine.VersionDigest
+	8, // [8:8] is the sub-list for method output_type
+	8, // [8:8] is the sub-list for method input_type
+	8, // [8:8] is the sub-list for extension type_name
+	8, // [8:8] is the sub-list for extension extendee
+	0, // [0:8] is the sub-list for field type_name
 }
 
 func init() { file_shac_proto_init() }
@@ -642,7 +732,7 @@
 			}
 		}
 		file_shac_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Requirements); i {
+			switch v := v.(*PassthroughEnv); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -654,7 +744,7 @@
 			}
 		}
 		file_shac_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Dependency); i {
+			switch v := v.(*Requirements); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -666,7 +756,7 @@
 			}
 		}
 		file_shac_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Sum); i {
+			switch v := v.(*Dependency); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -678,7 +768,7 @@
 			}
 		}
 		file_shac_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Known); i {
+			switch v := v.(*Sum); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -690,6 +780,18 @@
 			}
 		}
 		file_shac_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Known); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_shac_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*VersionDigest); i {
 			case 0:
 				return &v.state
@@ -708,7 +810,7 @@
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_shac_proto_rawDesc,
 			NumEnums:      0,
-			NumMessages:   7,
+			NumMessages:   8,
 			NumExtensions: 0,
 			NumServices:   0,
 		},
diff --git a/internal/engine/shac.proto b/internal/engine/shac.proto
index cfc6db6..be80118 100644
--- a/internal/engine/shac.proto
+++ b/internal/engine/shac.proto
@@ -40,6 +40,8 @@
   // are implemented.
   bool writable_root = 7;
   repeated Var vars = 8;
+  // Environment variables to pass through the sandbox.
+  repeated PassthroughEnv passthrough_env = 9;
 }
 
 // Var specifies a variable that may be passed into checks at runtime by the
@@ -59,6 +61,18 @@
   string default = 3;
 }
 
+// PassthroughEnv specifies an environment variable that should be passed
+// through into the sandbox.
+message PassthroughEnv {
+  // The name of the environment variable, e.g. "FOO".
+  string name = 1;
+  // Whether the environment variable's value is a file path that sandboxed
+  // processes should be granted access to.
+  bool is_path = 2;
+  // If is_path is true, whether to mount the file/directory as writeable.
+  bool writeable = 3;
+}
+
 // Requirements lists all the external dependencies, both direct and transitive
 // (indirect).
 message Requirements {
diff --git a/internal/engine/testdata/ctx-os-exec-passthrough_env.go b/internal/engine/testdata/ctx-os-exec-passthrough_env.go
new file mode 100644
index 0000000..5d12508
--- /dev/null
+++ b/internal/engine/testdata/ctx-os-exec-passthrough_env.go
@@ -0,0 +1,63 @@
+// Copyright 2023 The Shac Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build ignore
+
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+)
+
+func main() {
+	varPrefix := os.Args[1]
+
+	nonFileVarname := varPrefix + "VAR"
+	readOnlyDirVarname := varPrefix + "RO_DIR"
+	writeableDirVarname := varPrefix + "WRITEABLE_DIR"
+
+	fmt.Printf("non-file env var: %s\n", mustGetEnv(nonFileVarname))
+	fmt.Printf("read-only dir env var: %s\n", mustGetEnv(readOnlyDirVarname))
+	fmt.Printf("writeable dir env var: %s\n", mustGetEnv(writeableDirVarname))
+
+	// Make sure the writeable dir is writeable.
+	if _, err := os.ReadDir(mustGetEnv(writeableDirVarname)); err != nil {
+		log.Panicf("failed to read writeable dir: %s", err)
+	}
+	if err := os.WriteFile(filepath.Join(mustGetEnv(writeableDirVarname), "foo.txt"), []byte("hi"), 0o600); err == nil {
+		fmt.Println("able to write to writeable dir")
+	} else {
+		log.Panicf("failed to write to writeable dir: %s", err)
+	}
+
+	if _, err := os.ReadDir(mustGetEnv(readOnlyDirVarname)); err != nil {
+		log.Panicf("failed to read read-only dir: %s", err)
+	}
+	if err := os.WriteFile(filepath.Join(mustGetEnv(readOnlyDirVarname), "foo.txt"), []byte("hi"), 0o600); err == nil {
+		fmt.Println("able to write to read-only dir")
+	} else {
+		fmt.Printf("error writing to read-only dir: %s\n", err)
+	}
+}
+
+func mustGetEnv(name string) string {
+	val, ok := os.LookupEnv(name)
+	if !ok {
+		log.Panicf("env var %s is not set", name)
+	}
+	return val
+}
diff --git a/internal/engine/testdata/ctx-os-exec-passthrough_env.star b/internal/engine/testdata/ctx-os-exec-passthrough_env.star
new file mode 100644
index 0000000..b57659b
--- /dev/null
+++ b/internal/engine/testdata/ctx-os-exec-passthrough_env.star
@@ -0,0 +1,36 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+def cb(ctx):
+    res = ctx.os.exec(
+        [
+            "go",
+            "run",
+            "ctx-os-exec-passthrough_env.go",
+            ctx.vars.get("VAR_PREFIX"),
+        ],
+        env = _go_env(ctx),
+    ).wait()
+    print(res.stdout.rstrip())
+
+def _go_env(ctx):
+    return {
+        "CGO_ENABLED": "0",
+        "GOPACKAGESDRIVER": "off",
+        # Explicitly set GOROOT to prevent warnings about GOROOT and GOPATH being
+        # equal when they're both empty.
+        "GOROOT": ctx.os.exec(["go", "env", "GOROOT"]).wait().stdout.strip(),
+    }
+
+shac.register_check(cb)
diff --git a/internal/engine/testdata/fail_or_throw/ctx-os-exec-10Mib-exceed.star b/internal/engine/testdata/fail_or_throw/ctx-os-exec-10Mib-exceed.star
index b2174f8..5331290 100644
--- a/internal/engine/testdata/fail_or_throw/ctx-os-exec-10Mib-exceed.star
+++ b/internal/engine/testdata/fail_or_throw/ctx-os-exec-10Mib-exceed.star
@@ -19,7 +19,6 @@
 def _go_env(ctx):
     return {
         "CGO_ENABLED": "0",
-        "GOCACHE": ctx.io.tempdir() + "/gocache",
         "GOPACKAGESDRIVER": "off",
         # Explicitly set GOROOT to prevent warnings about GOROOT and GOPATH being
         # equal when they're both empty.
diff --git a/internal/engine/testdata/fail_or_throw/shac.textproto b/internal/engine/testdata/fail_or_throw/shac.textproto
new file mode 100644
index 0000000..b609bc2
--- /dev/null
+++ b/internal/engine/testdata/fail_or_throw/shac.textproto
@@ -0,0 +1,28 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Give access to $GOCACHE (and $HOME so $GOCACHE can be inferred if unset), so
+# checks that run `go run` can use cached artifacts.
+passthrough_env: [
+    {
+        name: "HOME",
+        is_path: true,
+        writeable: true
+    },
+    {
+        name: "GOCACHE",
+        is_path: true,
+        writeable: true
+    }
+]
\ No newline at end of file
diff --git a/internal/engine/testdata/print/ctx-os-exec-10Mib.star b/internal/engine/testdata/print/ctx-os-exec-10Mib.star
index 2023931..eba656f 100644
--- a/internal/engine/testdata/print/ctx-os-exec-10Mib.star
+++ b/internal/engine/testdata/print/ctx-os-exec-10Mib.star
@@ -19,7 +19,6 @@
 def _go_env(ctx):
     return {
         "CGO_ENABLED": "0",
-        "GOCACHE": ctx.io.tempdir() + "/gocache",
         "GOPACKAGESDRIVER": "off",
         # Explicitly set GOROOT to prevent warnings about GOROOT and GOPATH being
         # equal when they're both empty.
diff --git a/internal/engine/testdata/print/ctx-os-exec-stdin.star b/internal/engine/testdata/print/ctx-os-exec-stdin.star
index 6aa27cd..1939da0 100644
--- a/internal/engine/testdata/print/ctx-os-exec-stdin.star
+++ b/internal/engine/testdata/print/ctx-os-exec-stdin.star
@@ -32,7 +32,6 @@
 def _go_env(ctx):
     return {
         "CGO_ENABLED": "0",
-        "GOCACHE": ctx.io.tempdir() + "/gocache",
         "GOPACKAGESDRIVER": "off",
     }
 
diff --git a/internal/engine/testdata/print/shac.textproto b/internal/engine/testdata/print/shac.textproto
new file mode 100644
index 0000000..b609bc2
--- /dev/null
+++ b/internal/engine/testdata/print/shac.textproto
@@ -0,0 +1,28 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Give access to $GOCACHE (and $HOME so $GOCACHE can be inferred if unset), so
+# checks that run `go run` can use cached artifacts.
+passthrough_env: [
+    {
+        name: "HOME",
+        is_path: true,
+        writeable: true
+    },
+    {
+        name: "GOCACHE",
+        is_path: true,
+        writeable: true
+    }
+]
\ No newline at end of file
diff --git a/internal/engine/version.go b/internal/engine/version.go
index 8daf77c..ef48033 100644
--- a/internal/engine/version.go
+++ b/internal/engine/version.go
@@ -26,7 +26,7 @@
 	// Version is the current tool version.
 	//
 	// TODO(maruel): Add proper version, preferably from git tag.
-	Version = shacVersion{0, 1, 9}
+	Version = shacVersion{0, 1, 10}
 )
 
 func (v shacVersion) String() string {
diff --git a/scripts/tests.sh b/scripts/tests.sh
index d7276f2..8c6d20e 100755
--- a/scripts/tests.sh
+++ b/scripts/tests.sh
@@ -39,12 +39,14 @@
 fi
 
 echo "- Testing with coverage"
-go test -cover ./...
-
-echo ""
-echo "- Benchmarks"
-go test -bench=. -run=^$ -cpu 1 ./...
+go test -count=1 -cover ./...
 
 echo ""
 echo "- Running 'shac check'"
 go run . check -v
+
+# Benchmarks are the slowest step, so run them last in case the user only cares
+# about previous steps and wants to ctrl-C.
+echo ""
+echo "- Benchmarks"
+go test -count=1 -benchtime=200ms -bench=. -run=^$ -cpu 1 ./...
diff --git a/shac.textproto b/shac.textproto
index 31054f1..32697a0 100644
--- a/shac.textproto
+++ b/shac.textproto
@@ -15,7 +15,7 @@
 # TODO(olivernewman): Build github.com/protocolbuffers/txtpbfmt into shac and
 # enforce formatting of shac.textproto files in all repos that use shac.
 
-min_shac_version: "0.1.9"
+min_shac_version: "0.1.10"
 allow_network: False
 ignore: "/vendor/"
 # Vendored code for test data only.
@@ -32,3 +32,21 @@
         default: "foo"
     }
 ]
+passthrough_env: [
+    {
+        # Provide Go commands access to the Go cache to speed up compilation.
+        name: "GOCACHE",
+        is_path: true
+        writeable: true
+    },
+    {
+        # The Go cache directory is computed based on $HOME in the absence of
+        # $GOCACHE.
+        # TODO(olivernewman): Implement support for constructing pass-throughs
+        # using Starlark, and pass through the value returned by `go env
+        # GOCACHE` instead of all of $HOME.
+        name: "HOME",
+        is_path: true
+        writeable: true
+    }
+]