Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
2e2ea27
feat(sandbox): add Windows sandbox principals
Vasanthdev2004 Jul 26, 2026
718e064
feat(sandbox): use a sandbox principal for Windows command execution
Vasanthdev2004 Jul 26, 2026
f855802
test(sandbox): name the per-shell env syntax in the provisioning skip
Vasanthdev2004 Jul 26, 2026
a56e6f8
test(sandbox): cover the logon-rights and token-minting half
Vasanthdev2004 Jul 26, 2026
39e0e02
feat(sandbox): provision a sandbox principal during elevated setup
Vasanthdev2004 Jul 26, 2026
9bdfa4c
fix(sandbox): keep the restricted token when the network is denied
Vasanthdev2004 Jul 26, 2026
672f407
feat(sandbox): encrypt the stored principal password to the invoking …
Vasanthdev2004 Jul 26, 2026
38bac64
fix(sandbox): surface a squatted principal name instead of falling back
Vasanthdev2004 Jul 27, 2026
01dcfdc
fix(sandbox): reset the password when the account already exists
Vasanthdev2004 Jul 27, 2026
ae294d8
fix(sandbox): revoke logon rights before deleting a principal
Vasanthdev2004 Jul 27, 2026
b46d7d1
fix(sandbox): refuse a squatted account name and clean up partial pro…
Vasanthdev2004 Jul 27, 2026
706e1a4
fix(sandbox): drop the stored secret whenever provisioning fails
Vasanthdev2004 Jul 27, 2026
324d9f3
test(sandbox): check the ACE type before decoding its trustee
Vasanthdev2004 Jul 28, 2026
0b6b37e
fix(sandbox): grant delete to the principal and keep rollback able to…
Vasanthdev2004 Jul 28, 2026
815d220
fix(sandbox): stop setup destroying a principal it did not create
Vasanthdev2004 Jul 29, 2026
2398941
fix(sandbox): keep an adopted principal's logon rights on rollback
Vasanthdev2004 Jul 29, 2026
6d9b437
fix(sandbox): grant the principal the runtime tree commands write to
Vasanthdev2004 Jul 30, 2026
bf7a530
fix(sandbox): refuse to adopt a principal in a privileged group
Vasanthdev2004 Jul 30, 2026
1391376
fix(sandbox): materialize .git/config as a file, not a directory
Vasanthdev2004 Jul 30, 2026
6315181
fix(sandbox): re-check principal privilege when minting a command token
Vasanthdev2004 Jul 30, 2026
dfe7fa3
fix(sandbox): revoke stale principal ACEs before re-applying the plan
Vasanthdev2004 Jul 30, 2026
d0693ff
fix(sandbox): revoke ACEs on teardown and key setup off the resolved …
Vasanthdev2004 Jul 30, 2026
1945d47
fix(sandbox): canonicalize the workspace root on both sides, not just…
Vasanthdev2004 Jul 30, 2026
b1fb0ab
fix(sandbox): normalize the cache root too, not just the workspace root
Vasanthdev2004 Jul 30, 2026
e1c5756
fix(sandbox): resolve through path segments that do not exist yet
Vasanthdev2004 Jul 30, 2026
1a6b08f
test(sandbox): assert the ancestor walk, not the old all-or-nothing c…
Vasanthdev2004 Jul 30, 2026
1767a08
fix(sandbox): close the delete-through-parent, junction-ancestor and …
Vasanthdev2004 Jul 31, 2026
7681a95
fix(sandbox): stop teardown creating a directory while naming one
Vasanthdev2004 Jul 31, 2026
2d5dcfa
fix(sandbox): surface a failed stale-secret cleanup after rotation
Vasanthdev2004 Jul 31, 2026
11534a4
fix(sandbox): reject reparse ancestors before creating an ACL target
Vasanthdev2004 Aug 1, 2026
53fb2e8
fix(sandbox): keep the principal inside the Windows write jail
Vasanthdev2004 Aug 1, 2026
c13799d
fix(sandbox): carry the principal opt-in through the setup protocol
Vasanthdev2004 Aug 3, 2026
542728a
fix(sandbox): revoke principal ACEs on roots that left the policy
Vasanthdev2004 Aug 3, 2026
03e556b
test(sandbox): compare ACL record paths the way the plans do
Vasanthdev2004 Aug 3, 2026
66724c6
fix(sandbox): stop a principal replacing .git to shed its carveouts
Vasanthdev2004 Aug 7, 2026
9b54fb4
feat(sandbox): handle-relative directory create and delete on Windows
Vasanthdev2004 Aug 7, 2026
52d1a31
fix(sandbox): keep the git rename guard tests portable
Vasanthdev2004 Aug 8, 2026
9b9937b
fix(sandbox): bind windows ACL materialization and rollback to handles
Vasanthdev2004 Aug 8, 2026
0a80db4
fix(sandbox): reject protected metadata names that escape the write root
Vasanthdev2004 Aug 8, 2026
279c7bb
fix(sandbox): apply the .git rename guard on a workspace that had no …
Vasanthdev2004 Aug 8, 2026
37629a2
fix(sandbox): stop granting a principal read at the volume root, and …
Vasanthdev2004 Aug 8, 2026
b86bfc1
docs(sandbox): attribute the Users-group premise the volume-root fix …
Vasanthdev2004 Aug 8, 2026
3cb40ce
fix(sandbox): name the root that blocks unelevated ACL setup
Vasanthdev2004 Aug 8, 2026
c11e350
feat(sandbox): add `zero sandbox exec` to run one command through the…
Vasanthdev2004 Aug 8, 2026
8bb5546
fix(sandbox): fingerprint principal grants in the setup marker
Vasanthdev2004 Aug 8, 2026
4e1f483
fix(sandbox): report an inactive principal in doctor, and finish the …
Vasanthdev2004 Aug 9, 2026
8470925
fix(sandbox): retire the principal on opt-out, and finish teardown pa…
Vasanthdev2004 Aug 9, 2026
ff07755
fix(sandbox): serialize elevated setup with a per-workspace lock
Vasanthdev2004 Aug 9, 2026
09cc534
fix(sandbox): address jatmn's review on the principal backend and san…
Vasanthdev2004 Aug 10, 2026
d608a3c
fix(sandbox): keep elevated Windows setup tied to the caller's identity
Vasanthdev2004 Aug 10, 2026
0fda33e
fix(sandbox): fail opt-out only when the principal actually survived
Vasanthdev2004 Aug 10, 2026
67f3d61
fix(sandbox): refuse to adopt a ZeroSandboxUsers group Zero did not c…
Vasanthdev2004 Aug 10, 2026
c914d76
fix(sandbox): stop the principal's own SID from defeating its write jail
Vasanthdev2004 Aug 11, 2026
c981c3c
fix(sandbox): derive the fallback runtime root instead of minting one…
Vasanthdev2004 Aug 11, 2026
1826326
chore: drop a stray probe artifact from the branch
Vasanthdev2004 Aug 11, 2026
c736616
fix(sandbox): cover both runtime roots in the setup contract
Vasanthdev2004 Aug 11, 2026
16d7512
test(sandbox): make the jail aliasing assertion able to fail
Vasanthdev2004 Aug 11, 2026
2d755db
fix(sandbox): create the runtime roots setup grants
Vasanthdev2004 Aug 11, 2026
622371f
fix(sandbox): say what changed when the setup marker is rejected
Vasanthdev2004 Aug 11, 2026
1dd1709
fix(sandbox): derive the runtime roots where TEMP is the operator's
Vasanthdev2004 Aug 11, 2026
5197696
fix(sandbox): give the principal's read roots a restricting SID
Vasanthdev2004 Aug 11, 2026
1e29354
test(sandbox): fix the workspace-root literal in the setup-args tests
Vasanthdev2004 Aug 11, 2026
3944704
fix(sandbox): protect the gitdir pointer in a linked worktree
Vasanthdev2004 Aug 11, 2026
ab76cfe
fix(sandbox): write the principal secret through one pinned handle
Vasanthdev2004 Aug 12, 2026
1efc1af
fix(sandbox): report the privileges a principal launch actually needs
Vasanthdev2004 Aug 12, 2026
7cad854
fix(sandbox): stop the principal's environment naming the caller
Vasanthdev2004 Aug 12, 2026
aecd614
fix(sandbox): drop the unfollowable sandbox override from ACL failures
Vasanthdev2004 Aug 12, 2026
695ef64
fix(sandbox): stop the redirect check resolving what it checks for
Vasanthdev2004 Aug 12, 2026
e26e292
test(sandbox): compare normalized paths, not the raw input
Vasanthdev2004 Aug 12, 2026
d9b72ba
feat(sandbox): give each workspace an offline and an online principal…
Vasanthdev2004 Aug 21, 2026
3239565
fix(sandbox): bind the ACL restore to the object it snapshotted
Vasanthdev2004 Aug 21, 2026
746b6af
fix(sandbox): name the two role inventories, and fingerprint the whol…
Vasanthdev2004 Aug 21, 2026
ef8f5c9
fix(sandbox): refuse to provision a principal this caller could never…
Vasanthdev2004 Aug 24, 2026
a40b50f
test(sandbox): assert nothing was created, not that a count held still
Vasanthdev2004 Aug 27, 2026
16fea97
fix(sandbox): keep principal provisioning closed until a launch path …
Vasanthdev2004 Aug 31, 2026
55b8e26
test(sandbox): keep the runtime-root candidates inside test-owned sto…
Vasanthdev2004 Aug 31, 2026
41590d7
test(sandbox): stop the launch preflight test asserting a machine's t…
Vasanthdev2004 Aug 31, 2026
8af0599
fix(sandbox): root the fallback runtime tree beneath a validated priv…
Vasanthdev2004 Sep 2, 2026
31da8a9
fix(sandbox): resolve the temp dir physically before proving the fall…
Vasanthdev2004 Sep 2, 2026
e81ed96
fix(sandbox,cli): plan the grant and its guard together, and state th…
Vasanthdev2004 Sep 3, 2026
b203ad1
fix(sandbox): validate runtime candidates before the elevated create,…
Vasanthdev2004 Sep 3, 2026
88dc53d
fix(sandbox): check the runtime root still carries its grant, not jus…
Vasanthdev2004 Sep 3, 2026
cf40b86
fix(sandbox): take Everyone out of the restricted-SID list
Vasanthdev2004 Sep 3, 2026
759133c
test(sandbox): keep the SID helper from colliding with #886
Vasanthdev2004 Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion internal/cli/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const permissionProfileScopeNote = "permissionProfile is derived from this proce

func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy`, `zero sandbox exec`, or `zero sandbox grants list`.")
}
switch args[0] {
case "-h", "--help", "help":
Expand All @@ -36,6 +36,8 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return runSandboxSetup(args[1:], stdout, stderr, deps)
case "check":
return runSandboxCheck(args[1:], stdout, stderr, deps)
case "exec":
return runSandboxExec(args[1:], stdout, stderr, deps)
case "grants":
return runSandboxGrants(args[1:], stdout, stderr, deps)
default:
Expand Down Expand Up @@ -174,10 +176,17 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
if !setupHelper.Available() {
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
}
// Resolved here, in the shell the user typed `zero sandbox setup` into, and
// carried in the args. The helper may be launched elevated, and an elevated
// process does not inherit this shell's environment. Stated explicitly rather
// than left nil (which resolves the same way) because this is the call site
// the opt-in is about.
principalOptIn := zeroSandbox.WindowsSandboxPrincipalOptIn(nil)
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
PrincipalOptIn: &principalOptIn,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
Expand Down Expand Up @@ -646,6 +655,7 @@ Commands:
policy Inspect active sandbox policy and platform backend
setup Run native platform sandbox setup
check Evaluate the sandbox decision for a hypothetical tool action
exec Run one command through the real sandbox
grants Manage persistent sandbox grants

`)
Expand Down
204 changes: 204 additions & 0 deletions internal/cli/sandbox_exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package cli

import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"

"github.com/Gitlawb/zero/internal/config"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)

// runSandboxExec runs ONE command through the real sandbox and exits with its
// status.
//
// This exists because until now the sandbox could only be exercised through a
// full agent turn with a model in the loop. `zero sandbox policy` reports what
// the posture would be and `zero sandbox check` evaluates a hypothetical
// decision, but nothing actually ran a command and let you look at what
// happened on disk afterwards. The practical result is that enforcement is
// covered almost entirely by tests asserting the shape of an ACL plan, and
// almost not at all by tests asserting a write was refused.
//
// A plan can be perfectly correct and never reach the filesystem. That is not
// hypothetical here: the .git rename guard was emitted correctly by the planner
// and silently skipped by the applier, and four tests covering the plan all
// passed while the ACE was absent from disk. Something that runs the real
// binary and then stats the file is the only thing that catches that class.
//
// Deliberately NOT a debug curiosity: it takes the same path a shell tool
// takes, through SandboxManager.BuildCommandPlan, so what it proves is what
// users get. It prints the resolved backend and enforcement level to stderr
// before running, so a harness can assert the sandbox was actually engaged
// rather than quietly downgraded.
func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
command, err := parseSandboxExecArgs(args)
if err != nil {
if errors.Is(err, errSandboxExecHelp) {
if writeErr := writeSandboxExecHelp(stdout); writeErr != nil {
return exitCrash
}
return exitSuccess
}
return writeExecUsageError(stderr, err.Error())
}

workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)

scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return writeAppError(stderr, fmt.Sprintf("resolve sandbox write roots: %v", err), exitCrash)
}

// Through the ENGINE, not a SandboxManager built here.
//
// The engine is what a real tool call goes through, and it does more than
// hand the manager a request: it resolves the permission profile, calls
// prepareSandboxRuntime, and folds the runtime state into that profile
// before planning. Building a manager directly skipped all of it, so the
// command ran without the runtime write root, and cache or temp writes could
// pass or fail differently from the sandboxed command this exists to imitate.
// A harness that exercises the wrong path is worse than no harness, because
// its result still reads as evidence.
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: policy,
Scope: scope,
Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}),
// The same scrub list a real tool call gets. Without it the engine keeps an
// empty set, and only the hardcoded names plus the provider catalog's own
// AuthEnvVars are removed from the child's environment: a key named by
// `apiKeyEnv` in the user's config would be scrubbed for every sandboxed
// tool call and handed to this one. Reproducing the production environment
// is the entire point of the command, and a credential is the last part of
// it that may differ.
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
plan, err := engine.BuildCommandPlan(zeroSandbox.CommandSpec{
Name: command[0],
Args: command[1:],
Dir: workspaceRoot,
Env: os.Environ(),
})
if err != nil {
return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash)
}
// The plan owns resources beyond its construction: on Linux it allocates a
// policy-report file and registers the removal here, so without this every
// invocation leaves a /tmp/zero-sandbox-report-* behind.
defer plan.Cleanup()

// Printed before the command runs and on stderr, so it survives a command
// that writes to stdout and stays greppable by a test harness. A downgrade
// is reported loudly for the same reason: a smoke test that passes because
// the sandbox quietly stood down is worse than no smoke test.
fmt.Fprintf(stderr, "sandbox: backend=%s enforcement=%s wrapped=%t workspace=%s\n",
plan.Backend.Name, plan.EnforcementLevel, plan.Wrapped, plan.WorkspaceRoot)
if strings.TrimSpace(plan.DowngradeReason) != "" {
fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason)
}

return runSandboxPlannedCommand(plan, stdout, stderr)
}

func runSandboxPlannedCommand(plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int {
process := exec.Command(plan.Name, plan.Args...)
process.Dir = plan.Dir
if process.Dir == "" {
process.Dir = plan.WorkspaceRoot
}
if len(plan.Env) > 0 {
process.Env = plan.Env
}
process.Stdin = os.Stdin
process.Stdout = stdout
process.Stderr = stderr

if err := process.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// The command's own status, not ours. A harness asserting "the write
// was refused" needs the refusal's exit code, not a wrapper's.
// A SIGNALED CHILD HAS NO EXIT CODE TO REPORT. ExitCode() answers -1
// there, and os.Exit truncates that to 255, so a child that took SIGTERM
// became indistinguishable from one that chose to exit 255. Fold the
// signal into the conventional 128+n a shell would report, which needs
// the ProcessState rather than the integer.
if status, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
return status
}
return exitErr.ExitCode()
}
fmt.Fprintf(stderr, "sandbox exec: %v\n", err)
return exitCrash
}
return exitSuccess
}

var errSandboxExecHelp = errors.New("help requested")

// parseSandboxExecArgs takes everything after `--` as the command, so the
// command's own flags are never mistaken for ours.
func parseSandboxExecArgs(args []string) ([]string, error) {
if len(args) == 0 {
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
}
// Only the FIRST token is ours to interpret. Everything from the second
// onwards belongs to the child, help flags included.
//
// Written as a straight-line decision on args[0] rather than a loop, because
// a loop here is a lie. An earlier version scanned for `--` "up front" and
// said so in its comment, but every branch of its body returned, so it only
// ever examined index 0 and the promised scan did not exist. The rule below
// is what that code actually implemented, and it is the rule we want: it is
// the wrapper's own prefix that can ask for the wrapper's help, and the
// prefix is at most one token long.
switch args[0] {
case "--":
// Everything after the separator is the command, verbatim, including
// `--help`. That is the documented contract: `zero sandbox exec -- cmd
// --help` must run cmd's help, not ours.
command := args[1:]
if len(command) == 0 {
return nil, errors.New("usage: zero sandbox exec -- <command> [args...]")
}
return command, nil
case "-h", "--help", "help":
return nil, errSandboxExecHelp
}
// The separator-less form, tolerated for interactive use: the first token is
// the command, so nothing after it is ours to read. Without this,
// `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd,
// which is the same contract break as reading past `--`. The help text still
// shows the separator, because anything with a leading dash needs it.
return args, nil
}

func writeSandboxExecHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero sandbox exec -- <command> [args...]

Runs one command through the real sandbox and exits with its status.

Everything after the -- separator is the command, so its own flags are not
parsed as Zero's. The resolved backend and enforcement level are written to
stderr before the command runs, and a downgrade is reported there explicitly.

Examples:
zero sandbox exec -- cmd /c echo hello
zero sandbox exec -- powershell -Command "Set-Content out.txt x"

`)
return err
}
28 changes: 28 additions & 0 deletions internal/cli/sandbox_exec_signal_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build !windows

package cli

import (
"os"
"syscall"
)

// signaledExitStatus reports the conventional shell status for a child that was
// terminated by a signal, which exec.ExitError cannot represent.
//
// ExitCode() returns -1 for a signaled child, and the top level hands that to
// os.Exit, which truncates it to 255. A child that takes SIGTERM is then
// indistinguishable from one that exited 255 of its own accord, which breaks the
// documented status contract for a command whose whole job is to report the
// child's own status faithfully. 128+signal is what a shell reports and what a
// harness comparing statuses expects.
func signaledExitStatus(state *os.ProcessState) (int, bool) {
if state == nil {
return 0, false
}
status, ok := state.Sys().(syscall.WaitStatus)
if !ok || !status.Signaled() {
return 0, false
}
return 128 + int(status.Signal()), true
}
65 changes: 65 additions & 0 deletions internal/cli/sandbox_exec_signal_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//go:build !windows

package cli

import (
"errors"
"os/exec"
"syscall"
"testing"
)

// A SIGNALED CHILD MUST NOT LOOK LIKE ONE THAT CHOSE TO EXIT 255.
//
// `sandbox exec` exists to report the child's own status faithfully, and
// exec.ExitError cannot represent signal termination: ExitCode() answers -1, and
// the top level hands that to os.Exit, which truncates it to 255. So a child
// killed by SIGTERM was indistinguishable from an ordinary exit of 255, and a
// harness comparing statuses could not tell a refusal from a kill.
//
// Driven with a real subprocess and a real signal rather than a synthetic
// WaitStatus, because the mapping is only correct if the OS agrees with it.
func TestSignaledChildReportsTheConventionalStatus(t *testing.T) {
for _, testCase := range []struct {
name string
script string
want int
}{
{name: "SIGTERM", script: "kill -TERM $$; sleep 5", want: 128 + int(syscall.SIGTERM)},
{name: "SIGINT", script: "kill -INT $$; sleep 5", want: 128 + int(syscall.SIGINT)},
} {
t.Run(testCase.name, func(t *testing.T) {
err := exec.Command("/bin/sh", "-c", testCase.script).Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
}
// The behaviour being corrected: the integer alone cannot say this.
if code := exitErr.ExitCode(); code != -1 {
t.Fatalf("SETUP INVALID: a signaled child reported exit code %d, expected -1 on this platform", code)
}
status, signaled := signaledExitStatus(exitErr.ProcessState)
if !signaled {
t.Fatal("a signaled child was not recognised as signaled, so it would be reported as exit 255")
}
if status != testCase.want {
t.Fatalf("status = %d, want %d (128 + signal), which is what a shell reports", status, testCase.want)
}
})
}
}

// An ordinary non-zero exit is untouched: it has a real code and must keep it.
func TestOrdinaryExitIsNotTreatedAsSignaled(t *testing.T) {
err := exec.Command("/bin/sh", "-c", "exit 3").Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err)
}
if _, signaled := signaledExitStatus(exitErr.ProcessState); signaled {
t.Fatal("an ordinary exit was reported as signaled, which would rewrite its status")
}
if code := exitErr.ExitCode(); code != 3 {
t.Fatalf("exit code = %d, want the child's own 3", code)
}
}
12 changes: 12 additions & 0 deletions internal/cli/sandbox_exec_signal_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build windows

package cli

import "os"

// signaledExitStatus has no Windows counterpart: a process there ends with an
// exit code, and there is no signal to fold into one. Windows keeps the exit
// code exec.ExitError already reports.
func signaledExitStatus(*os.ProcessState) (int, bool) {
return 0, false
}
Loading
Loading