-
Notifications
You must be signed in to change notification settings - Fork 181
feat(sandbox): Windows sandbox principals (foundation for #662, does not close it) #808
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Vasanthdev2004
wants to merge
104
commits into
main
Choose a base branch
from
feat/windows-sandbox-identity
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
104 commits
Select commit
Hold shift + click to select a range
17aa51b
feat(sandbox): add Windows sandbox principals
Vasanthdev2004 ea7c04c
feat(sandbox): use a sandbox principal for Windows command execution
Vasanthdev2004 8dbe818
test(sandbox): name the per-shell env syntax in the provisioning skip
Vasanthdev2004 273bded
test(sandbox): cover the logon-rights and token-minting half
Vasanthdev2004 645976d
feat(sandbox): provision a sandbox principal during elevated setup
Vasanthdev2004 3e238cc
fix(sandbox): keep the restricted token when the network is denied
Vasanthdev2004 0b8fcf4
feat(sandbox): encrypt the stored principal password to the invoking …
Vasanthdev2004 949e104
fix(sandbox): surface a squatted principal name instead of falling back
Vasanthdev2004 07f6492
fix(sandbox): reset the password when the account already exists
Vasanthdev2004 a527ffc
fix(sandbox): revoke logon rights before deleting a principal
Vasanthdev2004 17879dc
fix(sandbox): refuse a squatted account name and clean up partial pro…
Vasanthdev2004 17d5674
fix(sandbox): drop the stored secret whenever provisioning fails
Vasanthdev2004 e8a0642
test(sandbox): check the ACE type before decoding its trustee
Vasanthdev2004 206046b
fix(sandbox): grant delete to the principal and keep rollback able to…
Vasanthdev2004 fd27716
fix(sandbox): stop setup destroying a principal it did not create
Vasanthdev2004 2175366
fix(sandbox): keep an adopted principal's logon rights on rollback
Vasanthdev2004 f0bc1a3
fix(sandbox): grant the principal the runtime tree commands write to
Vasanthdev2004 73fc1c0
fix(sandbox): refuse to adopt a principal in a privileged group
Vasanthdev2004 700e749
fix(sandbox): materialize .git/config as a file, not a directory
Vasanthdev2004 6ff2601
fix(sandbox): re-check principal privilege when minting a command token
Vasanthdev2004 8a84471
fix(sandbox): revoke stale principal ACEs before re-applying the plan
Vasanthdev2004 227f4ed
fix(sandbox): revoke ACEs on teardown and key setup off the resolved …
Vasanthdev2004 c6921d4
fix(sandbox): canonicalize the workspace root on both sides, not just…
Vasanthdev2004 0b5950e
fix(sandbox): normalize the cache root too, not just the workspace root
Vasanthdev2004 8d42270
fix(sandbox): resolve through path segments that do not exist yet
Vasanthdev2004 6b54ac0
test(sandbox): assert the ancestor walk, not the old all-or-nothing c…
Vasanthdev2004 7e996fa
fix(sandbox): close the delete-through-parent, junction-ancestor and …
Vasanthdev2004 d685d83
fix(sandbox): stop teardown creating a directory while naming one
Vasanthdev2004 7070993
fix(sandbox): surface a failed stale-secret cleanup after rotation
Vasanthdev2004 97c8c98
fix(sandbox): reject reparse ancestors before creating an ACL target
Vasanthdev2004 949ee3d
fix(sandbox): keep the principal inside the Windows write jail
Vasanthdev2004 f18e645
fix(sandbox): carry the principal opt-in through the setup protocol
Vasanthdev2004 cae96b2
fix(sandbox): revoke principal ACEs on roots that left the policy
Vasanthdev2004 7f45a7c
test(sandbox): compare ACL record paths the way the plans do
Vasanthdev2004 bcae716
fix(sandbox): stop a principal replacing .git to shed its carveouts
Vasanthdev2004 2d5e1f7
feat(sandbox): handle-relative directory create and delete on Windows
Vasanthdev2004 c2cf7b8
fix(sandbox): keep the git rename guard tests portable
Vasanthdev2004 011c4da
fix(sandbox): bind windows ACL materialization and rollback to handles
Vasanthdev2004 6a1b456
fix(sandbox): reject protected metadata names that escape the write root
Vasanthdev2004 d2968ae
fix(sandbox): apply the .git rename guard on a workspace that had no …
Vasanthdev2004 c3d041b
fix(sandbox): stop granting a principal read at the volume root, and …
Vasanthdev2004 b555357
docs(sandbox): attribute the Users-group premise the volume-root fix …
Vasanthdev2004 a18523a
fix(sandbox): name the root that blocks unelevated ACL setup
Vasanthdev2004 2ca6ac6
feat(sandbox): add `zero sandbox exec` to run one command through the…
Vasanthdev2004 d43362a
fix(sandbox): fingerprint principal grants in the setup marker
Vasanthdev2004 f96b8b2
fix(sandbox): report an inactive principal in doctor, and finish the …
Vasanthdev2004 05d7c2f
fix(sandbox): retire the principal on opt-out, and finish teardown pa…
Vasanthdev2004 229a6ac
fix(sandbox): serialize elevated setup with a per-workspace lock
Vasanthdev2004 47f26f6
fix(sandbox): address jatmn's review on the principal backend and san…
Vasanthdev2004 d0a0506
fix(sandbox): keep elevated Windows setup tied to the caller's identity
Vasanthdev2004 8dd74e2
fix(sandbox): fail opt-out only when the principal actually survived
Vasanthdev2004 9cb9139
fix(sandbox): refuse to adopt a ZeroSandboxUsers group Zero did not c…
Vasanthdev2004 8c242fc
fix(sandbox): stop the principal's own SID from defeating its write jail
Vasanthdev2004 602b21f
fix(sandbox): derive the fallback runtime root instead of minting one…
Vasanthdev2004 282a29f
chore: drop a stray probe artifact from the branch
Vasanthdev2004 9fa8624
fix(sandbox): cover both runtime roots in the setup contract
Vasanthdev2004 03c7783
test(sandbox): make the jail aliasing assertion able to fail
Vasanthdev2004 6e342ce
fix(sandbox): create the runtime roots setup grants
Vasanthdev2004 bfa40a7
fix(sandbox): say what changed when the setup marker is rejected
Vasanthdev2004 18ffd02
fix(sandbox): derive the runtime roots where TEMP is the operator's
Vasanthdev2004 9800065
fix(sandbox): give the principal's read roots a restricting SID
Vasanthdev2004 55023a9
test(sandbox): fix the workspace-root literal in the setup-args tests
Vasanthdev2004 b97e744
fix(sandbox): protect the gitdir pointer in a linked worktree
Vasanthdev2004 b9ba54a
fix(sandbox): write the principal secret through one pinned handle
Vasanthdev2004 81c8326
fix(sandbox): report the privileges a principal launch actually needs
Vasanthdev2004 2a76544
fix(sandbox): stop the principal's environment naming the caller
Vasanthdev2004 474f6e9
fix(sandbox): drop the unfollowable sandbox override from ACL failures
Vasanthdev2004 129800b
fix(sandbox): stop the redirect check resolving what it checks for
Vasanthdev2004 d736aae
test(sandbox): compare normalized paths, not the raw input
Vasanthdev2004 272acb9
feat(sandbox): give each workspace an offline and an online principal…
Vasanthdev2004 fffc780
fix(sandbox): bind the ACL restore to the object it snapshotted
Vasanthdev2004 3574f62
fix(sandbox): name the two role inventories, and fingerprint the whol…
Vasanthdev2004 d6b4e73
fix(sandbox): refuse to provision a principal this caller could never…
Vasanthdev2004 6f0d453
test(sandbox): assert nothing was created, not that a count held still
Vasanthdev2004 cd78050
fix(sandbox): keep principal provisioning closed until a launch path …
Vasanthdev2004 89b2e6d
test(sandbox): keep the runtime-root candidates inside test-owned sto…
Vasanthdev2004 c3e5e4b
test(sandbox): stop the launch preflight test asserting a machine's t…
Vasanthdev2004 8da16b4
fix(sandbox): root the fallback runtime tree beneath a validated priv…
Vasanthdev2004 834c184
fix(sandbox): resolve the temp dir physically before proving the fall…
Vasanthdev2004 faa07e3
fix(sandbox,cli): plan the grant and its guard together, and state th…
Vasanthdev2004 2925a67
fix(sandbox): validate runtime candidates before the elevated create,…
Vasanthdev2004 0904d88
fix(sandbox): check the runtime root still carries its grant, not jus…
Vasanthdev2004 5a43074
fix(sandbox): recheck the runtime object on the unelevated tier too
Vasanthdev2004 bd7e98c
fix(sandbox): make the capability git guard reach a workspace that ge…
Vasanthdev2004 4320d08
fix(sandbox): refuse a denyRead profile instead of ACLing the volume …
Vasanthdev2004 7920d3b
fix(sandbox): key the read-grant refusal on the grant, not on the vol…
Vasanthdev2004 26958ac
fix(sandbox): do not synthesize a .git inside an ancestor repository
Vasanthdev2004 30d3c47
fix(cli): keep a specified-empty sandbox environment from inheriting
Vasanthdev2004 a8b295d
test(sandbox): stop probing for privilege by mutating System32
Vasanthdev2004 daf58c8
fix(sandbox): keep a linked worktree's .git typed as a file through p…
Vasanthdev2004 69c68b1
fix(sandbox): stop following sandbox-controlled runtime descendants
Vasanthdev2004 673b84d
test(sandbox): drive the runtime-tree descent through its entry point
Vasanthdev2004 9608817
fix(sandbox): restore a raced leaf whose parent this run created
Vasanthdev2004 4234357
test(sandbox): reproduce the raced leaf at the instant it can actuall…
Vasanthdev2004 8099409
fix(cli): terminate the sandboxed command when the wrapper is cancelled
Vasanthdev2004 a186eb4
fix(sandbox): decide the runtime base on a canonical spelling
Vasanthdev2004 c887961
fix(cli): give a cancelled sandboxed command a graceful phase
Vasanthdev2004 95c8ac6
fix(sandbox): stop git's global options hiding the subcommand
Vasanthdev2004 d376b7f
fix(sandbox): refuse git init in a workspace inside another repository
Vasanthdev2004 9abab73
test(cli): assert the shutdown status and that nothing outlives the run
Vasanthdev2004 b12b2d7
fix(sandbox): build the elevated secret and runtime directories no-fo…
Vasanthdev2004 a45d400
fix(sandbox): keep the injectable lstat seam through the carveout specs
Vasanthdev2004 8b7ffa4
fix(sandbox): treat only a regular .git as the worktree pointer
Vasanthdev2004 a2e9558
fix(sandbox): remove a failed secret through the handle that created it
Vasanthdev2004 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "os/exec" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/Gitlawb/zero/internal/config" | ||
| "github.com/Gitlawb/zero/internal/execution" | ||
| 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) | ||
| } | ||
|
|
||
| // The CLI's shared shutdown context, so Ctrl+C and a directed SIGTERM both | ||
| // arrive here rather than only at whatever the terminal happens to signal. | ||
| runCtx, stopSignals := signalContext() | ||
| defer stopSignals() | ||
| return runSandboxPlannedCommand(runCtx, plan, stdout, stderr) | ||
| } | ||
|
|
||
| const ( | ||
| // sandboxExecShutdownGrace is how long a cancelled child has to exit on its | ||
| // own after the graceful request, before it is killed outright. | ||
| sandboxExecShutdownGrace = 5 * time.Second | ||
| // sandboxExecShutdownPoll is how often that wait re-checks, matching the | ||
| // background package's termination policy. | ||
| sandboxExecShutdownPoll = 50 * time.Millisecond | ||
| ) | ||
|
|
||
| func runSandboxPlannedCommand(ctx context.Context, plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int { | ||
| // CANCELLING THE WRAPPER HAS TO REACH THE COMMAND. | ||
| // | ||
| // This started the backend wrapper with a bare exec.Command().Run(): no | ||
| // context, no forwarding, no shutdown path. A terminal masks it, because | ||
| // terminals signal the whole foreground process group, but a supervisor or | ||
| // task runner that sends SIGTERM to the zero sandbox exec PID killed only | ||
| // Zero. The wrapper and everything under it kept running, doing filesystem | ||
| // and network work after the caller considered the task cancelled, and the | ||
| // deferred plan cleanup never ran because the process was gone. | ||
| // | ||
| // DELIBERATELY NOT execution.ConfigureProcessGroup. Putting the child in its | ||
| // own process group closes this hole and opens a worse one: it severs every | ||
| // kernel-delivered group signal, so a supervisor escalating to a group kill, | ||
| // and a terminal delivering Ctrl+C to its foreground group, would stop | ||
| // reaching the child. That trades a path that works today for one that does | ||
| // not. Keeping the child in Zero's group leaves group delivery unchanged, | ||
| // while the directed-signal case, which reached nothing at all before, now | ||
| // reaches the child. | ||
| process := exec.CommandContext(ctx, plan.Name, plan.Args...) | ||
| // TWO PHASES, AND THE FIRST ONE HAS TO EXIST. | ||
| // | ||
| // The comments here used to describe a graceful signal followed by escalation | ||
| // while the code did neither: Cancel called KillProcessTree, which is SIGKILL | ||
| // on Unix and taskkill /T /F on Windows, and WaitDelay only starts counting | ||
| // AFTER Cancel returns, so it could never postpone a kill that had already | ||
| // happened. A directed SIGTERM reached the child as 137 rather than 143, with | ||
| // no chance to flush output, drop a lock file, or run a shutdown handler. | ||
| // | ||
| // TerminateProcessTree is the two-phase policy the background package already | ||
| // uses: SIGTERM, poll for the grace, then SIGKILL what is still alive. On | ||
| // Windows it is the single-phase kill, which is correct rather than lazy, | ||
| // because there is no signal to send a child that owns no console. | ||
| process.Cancel = func() error { | ||
| return execution.TerminateProcessTree(process.Process.Pid, sandboxExecShutdownGrace, sandboxExecShutdownPoll) | ||
| } | ||
| // A backstop only. Cancel above already waits out the grace and escalates, so | ||
| // this bounds the case where the tree is gone but Wait is still blocked on an | ||
| // inherited pipe a grandchild holds open. | ||
| process.WaitDelay = sandboxExecShutdownGrace + sandboxExecShutdownPoll | ||
| process.Dir = plan.Dir | ||
| if process.Dir == "" { | ||
| process.Dir = plan.WorkspaceRoot | ||
| } | ||
| // SPECIFIED-EMPTY IS NOT UNSPECIFIED. | ||
| // | ||
| // exec.Cmd treats a nil Env as "inherit this process's entire environment", | ||
| // which is a different statement from "run with no variables". The plan owns | ||
| // its environment: directCommandEnv and scrubSensitiveEnv return a slice they | ||
| // built, and that slice is non-nil with length zero when every entry was | ||
| // sensitive. Testing length collapsed those two states and turned the strictest | ||
| // possible answer into the loosest one. | ||
| // | ||
| // Not reachable through `zero sandbox exec` today, because the child | ||
| // environment is os.Environ() and an environment holding only sensitive keys | ||
| // has no %AppData%, so config resolution fails before the planner runs. Fixed | ||
| // anyway: the guard is one assignment, and the next caller that hands the plan | ||
| // a deliberately narrow environment would inherit everything instead, silently. | ||
| if plan.Env != nil { | ||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "os" | ||
| "runtime" | ||
| "testing" | ||
| "time" | ||
|
|
||
| zeroSandbox "github.com/Gitlawb/zero/internal/sandbox" | ||
| ) | ||
|
|
||
| // longLivedPlan returns a plan whose child outlives the test unless something | ||
| // terminates it. | ||
| func longLivedPlan(t *testing.T) zeroSandbox.CommandPlan { | ||
| t.Helper() | ||
| plan := zeroSandbox.CommandPlan{Dir: t.TempDir()} | ||
| if runtime.GOOS == "windows" { | ||
| plan.Name = "cmd.exe" | ||
| plan.Args = []string{"/c", "ping -n 120 127.0.0.1 >NUL"} | ||
| return plan | ||
| } | ||
| plan.Name = "/bin/sh" | ||
| plan.Args = []string{"-c", "sleep 120"} | ||
| return plan | ||
| } | ||
|
|
||
| // CANCELLING THE WRAPPER HAS TO REACH THE COMMAND. | ||
| // | ||
| // The sandboxed command was started with a bare exec.Command().Run(): no | ||
| // context, no forwarding, no shutdown path. A terminal masks that, because it | ||
| // signals the whole foreground process group, but a supervisor or task runner | ||
| // that sends SIGTERM to the wrapper's PID killed only Zero. The command kept | ||
| // running, doing filesystem and network work after the caller considered the | ||
| // task cancelled, and the deferred plan cleanup never ran. | ||
| // | ||
| // Driven with a real long-lived child and a real cancellation, asserting that | ||
| // the call actually returns rather than that a field is set. | ||
| func TestCancellingTheWrapperTerminatesTheSandboxedCommand(t *testing.T) { | ||
| ctx, cancel := context.WithCancel(context.Background()) | ||
|
|
||
| done := make(chan int, 1) | ||
| go func() { | ||
| done <- runSandboxPlannedCommand(ctx, longLivedPlan(t), io.Discard, io.Discard) | ||
| }() | ||
|
|
||
| // Let the child actually start, or cancelling proves nothing. | ||
| time.Sleep(300 * time.Millisecond) | ||
| select { | ||
| case <-done: | ||
| t.Fatal("SETUP INVALID: the child exited on its own, so cancellation was not exercised") | ||
| default: | ||
| } | ||
|
|
||
| cancel() | ||
| select { | ||
| case <-done: | ||
| case <-time.After(sandboxExecShutdownGrace + 10*time.Second): | ||
| t.Fatal("cancelling the wrapper did not terminate the sandboxed command; it would keep running after the caller gave up") | ||
| } | ||
| } | ||
|
|
||
| // And an uncancelled command still runs to completion and reports its own | ||
| // status, or the fix above would be "kill everything immediately". | ||
| func TestAnUncancelledSandboxedCommandStillReportsItsStatus(t *testing.T) { | ||
| plan := zeroSandbox.CommandPlan{Dir: t.TempDir()} | ||
| if runtime.GOOS == "windows" { | ||
| plan.Name = "cmd.exe" | ||
| plan.Args = []string{"/c", "exit 3"} | ||
| } else { | ||
| plan.Name = "/bin/sh" | ||
| plan.Args = []string{"-c", "exit 3"} | ||
| } | ||
| if code := runSandboxPlannedCommand(context.Background(), plan, io.Discard, os.Stderr); code != 3 { | ||
| t.Fatalf("exit code = %d, want the child's own 3", code) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.