Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
129 commits
Select commit Hold shift + click to select a range
17aa51b
feat(sandbox): add Windows sandbox principals
Vasanthdev2004 Jul 26, 2026
ea7c04c
feat(sandbox): use a sandbox principal for Windows command execution
Vasanthdev2004 Jul 26, 2026
8dbe818
test(sandbox): name the per-shell env syntax in the provisioning skip
Vasanthdev2004 Jul 26, 2026
273bded
test(sandbox): cover the logon-rights and token-minting half
Vasanthdev2004 Jul 26, 2026
645976d
feat(sandbox): provision a sandbox principal during elevated setup
Vasanthdev2004 Jul 26, 2026
3e238cc
fix(sandbox): keep the restricted token when the network is denied
Vasanthdev2004 Jul 26, 2026
0b8fcf4
feat(sandbox): encrypt the stored principal password to the invoking …
Vasanthdev2004 Jul 26, 2026
949e104
fix(sandbox): surface a squatted principal name instead of falling back
Vasanthdev2004 Jul 27, 2026
07f6492
fix(sandbox): reset the password when the account already exists
Vasanthdev2004 Jul 27, 2026
a527ffc
fix(sandbox): revoke logon rights before deleting a principal
Vasanthdev2004 Jul 27, 2026
17879dc
fix(sandbox): refuse a squatted account name and clean up partial pro…
Vasanthdev2004 Jul 27, 2026
17d5674
fix(sandbox): drop the stored secret whenever provisioning fails
Vasanthdev2004 Jul 27, 2026
e8a0642
test(sandbox): check the ACE type before decoding its trustee
Vasanthdev2004 Jul 28, 2026
206046b
fix(sandbox): grant delete to the principal and keep rollback able to…
Vasanthdev2004 Jul 28, 2026
fd27716
fix(sandbox): stop setup destroying a principal it did not create
Vasanthdev2004 Jul 29, 2026
2175366
fix(sandbox): keep an adopted principal's logon rights on rollback
Vasanthdev2004 Jul 29, 2026
f0bc1a3
fix(sandbox): grant the principal the runtime tree commands write to
Vasanthdev2004 Jul 30, 2026
73fc1c0
fix(sandbox): refuse to adopt a principal in a privileged group
Vasanthdev2004 Jul 30, 2026
700e749
fix(sandbox): materialize .git/config as a file, not a directory
Vasanthdev2004 Jul 30, 2026
6ff2601
fix(sandbox): re-check principal privilege when minting a command token
Vasanthdev2004 Jul 30, 2026
8a84471
fix(sandbox): revoke stale principal ACEs before re-applying the plan
Vasanthdev2004 Jul 30, 2026
227f4ed
fix(sandbox): revoke ACEs on teardown and key setup off the resolved …
Vasanthdev2004 Jul 30, 2026
c6921d4
fix(sandbox): canonicalize the workspace root on both sides, not just…
Vasanthdev2004 Jul 30, 2026
0b5950e
fix(sandbox): normalize the cache root too, not just the workspace root
Vasanthdev2004 Jul 30, 2026
8d42270
fix(sandbox): resolve through path segments that do not exist yet
Vasanthdev2004 Jul 30, 2026
6b54ac0
test(sandbox): assert the ancestor walk, not the old all-or-nothing c…
Vasanthdev2004 Jul 30, 2026
7e996fa
fix(sandbox): close the delete-through-parent, junction-ancestor and …
Vasanthdev2004 Jul 31, 2026
d685d83
fix(sandbox): stop teardown creating a directory while naming one
Vasanthdev2004 Jul 31, 2026
7070993
fix(sandbox): surface a failed stale-secret cleanup after rotation
Vasanthdev2004 Jul 31, 2026
97c8c98
fix(sandbox): reject reparse ancestors before creating an ACL target
Vasanthdev2004 Aug 1, 2026
949ee3d
fix(sandbox): keep the principal inside the Windows write jail
Vasanthdev2004 Aug 1, 2026
f18e645
fix(sandbox): carry the principal opt-in through the setup protocol
Vasanthdev2004 Aug 3, 2026
cae96b2
fix(sandbox): revoke principal ACEs on roots that left the policy
Vasanthdev2004 Aug 3, 2026
7f45a7c
test(sandbox): compare ACL record paths the way the plans do
Vasanthdev2004 Aug 3, 2026
bcae716
fix(sandbox): stop a principal replacing .git to shed its carveouts
Vasanthdev2004 Aug 7, 2026
2d5e1f7
feat(sandbox): handle-relative directory create and delete on Windows
Vasanthdev2004 Aug 7, 2026
c2cf7b8
fix(sandbox): keep the git rename guard tests portable
Vasanthdev2004 Aug 8, 2026
011c4da
fix(sandbox): bind windows ACL materialization and rollback to handles
Vasanthdev2004 Aug 8, 2026
6a1b456
fix(sandbox): reject protected metadata names that escape the write root
Vasanthdev2004 Aug 8, 2026
d2968ae
fix(sandbox): apply the .git rename guard on a workspace that had no …
Vasanthdev2004 Aug 8, 2026
c3d041b
fix(sandbox): stop granting a principal read at the volume root, and …
Vasanthdev2004 Aug 8, 2026
b555357
docs(sandbox): attribute the Users-group premise the volume-root fix …
Vasanthdev2004 Aug 8, 2026
a18523a
fix(sandbox): name the root that blocks unelevated ACL setup
Vasanthdev2004 Aug 8, 2026
2ca6ac6
feat(sandbox): add `zero sandbox exec` to run one command through the…
Vasanthdev2004 Aug 8, 2026
d43362a
fix(sandbox): fingerprint principal grants in the setup marker
Vasanthdev2004 Aug 8, 2026
f96b8b2
fix(sandbox): report an inactive principal in doctor, and finish the …
Vasanthdev2004 Aug 9, 2026
05d7c2f
fix(sandbox): retire the principal on opt-out, and finish teardown pa…
Vasanthdev2004 Aug 9, 2026
229a6ac
fix(sandbox): serialize elevated setup with a per-workspace lock
Vasanthdev2004 Aug 9, 2026
47f26f6
fix(sandbox): address jatmn's review on the principal backend and san…
Vasanthdev2004 Aug 10, 2026
d0a0506
fix(sandbox): keep elevated Windows setup tied to the caller's identity
Vasanthdev2004 Aug 10, 2026
8dd74e2
fix(sandbox): fail opt-out only when the principal actually survived
Vasanthdev2004 Aug 10, 2026
9cb9139
fix(sandbox): refuse to adopt a ZeroSandboxUsers group Zero did not c…
Vasanthdev2004 Aug 10, 2026
8c242fc
fix(sandbox): stop the principal's own SID from defeating its write jail
Vasanthdev2004 Aug 11, 2026
602b21f
fix(sandbox): derive the fallback runtime root instead of minting one…
Vasanthdev2004 Aug 11, 2026
282a29f
chore: drop a stray probe artifact from the branch
Vasanthdev2004 Aug 11, 2026
9fa8624
fix(sandbox): cover both runtime roots in the setup contract
Vasanthdev2004 Aug 11, 2026
03c7783
test(sandbox): make the jail aliasing assertion able to fail
Vasanthdev2004 Aug 11, 2026
6e342ce
fix(sandbox): create the runtime roots setup grants
Vasanthdev2004 Aug 11, 2026
bfa40a7
fix(sandbox): say what changed when the setup marker is rejected
Vasanthdev2004 Aug 11, 2026
18ffd02
fix(sandbox): derive the runtime roots where TEMP is the operator's
Vasanthdev2004 Aug 11, 2026
9800065
fix(sandbox): give the principal's read roots a restricting SID
Vasanthdev2004 Aug 11, 2026
55023a9
test(sandbox): fix the workspace-root literal in the setup-args tests
Vasanthdev2004 Aug 11, 2026
b97e744
fix(sandbox): protect the gitdir pointer in a linked worktree
Vasanthdev2004 Aug 11, 2026
b9ba54a
fix(sandbox): write the principal secret through one pinned handle
Vasanthdev2004 Aug 12, 2026
81c8326
fix(sandbox): report the privileges a principal launch actually needs
Vasanthdev2004 Aug 12, 2026
2a76544
fix(sandbox): stop the principal's environment naming the caller
Vasanthdev2004 Aug 12, 2026
474f6e9
fix(sandbox): drop the unfollowable sandbox override from ACL failures
Vasanthdev2004 Aug 12, 2026
129800b
fix(sandbox): stop the redirect check resolving what it checks for
Vasanthdev2004 Aug 12, 2026
d736aae
test(sandbox): compare normalized paths, not the raw input
Vasanthdev2004 Aug 12, 2026
272acb9
feat(sandbox): give each workspace an offline and an online principal…
Vasanthdev2004 Aug 21, 2026
fffc780
fix(sandbox): bind the ACL restore to the object it snapshotted
Vasanthdev2004 Aug 21, 2026
3574f62
fix(sandbox): name the two role inventories, and fingerprint the whol…
Vasanthdev2004 Aug 21, 2026
d6b4e73
fix(sandbox): refuse to provision a principal this caller could never…
Vasanthdev2004 Aug 24, 2026
6f0d453
test(sandbox): assert nothing was created, not that a count held still
Vasanthdev2004 Aug 27, 2026
cd78050
fix(sandbox): keep principal provisioning closed until a launch path …
Vasanthdev2004 Aug 31, 2026
89b2e6d
test(sandbox): keep the runtime-root candidates inside test-owned sto…
Vasanthdev2004 Aug 31, 2026
c3e5e4b
test(sandbox): stop the launch preflight test asserting a machine's t…
Vasanthdev2004 Aug 31, 2026
8da16b4
fix(sandbox): root the fallback runtime tree beneath a validated priv…
Vasanthdev2004 Sep 2, 2026
834c184
fix(sandbox): resolve the temp dir physically before proving the fall…
Vasanthdev2004 Sep 2, 2026
faa07e3
fix(sandbox,cli): plan the grant and its guard together, and state th…
Vasanthdev2004 Sep 3, 2026
2925a67
fix(sandbox): validate runtime candidates before the elevated create,…
Vasanthdev2004 Sep 3, 2026
0904d88
fix(sandbox): check the runtime root still carries its grant, not jus…
Vasanthdev2004 Sep 3, 2026
5a43074
fix(sandbox): recheck the runtime object on the unelevated tier too
Vasanthdev2004 Sep 4, 2026
bd7e98c
fix(sandbox): make the capability git guard reach a workspace that ge…
Vasanthdev2004 Sep 4, 2026
4320d08
fix(sandbox): refuse a denyRead profile instead of ACLing the volume …
Vasanthdev2004 Sep 4, 2026
7920d3b
fix(sandbox): key the read-grant refusal on the grant, not on the vol…
Vasanthdev2004 Sep 4, 2026
26958ac
fix(sandbox): do not synthesize a .git inside an ancestor repository
Vasanthdev2004 Sep 4, 2026
30d3c47
fix(cli): keep a specified-empty sandbox environment from inheriting
Vasanthdev2004 Sep 4, 2026
a8b295d
test(sandbox): stop probing for privilege by mutating System32
Vasanthdev2004 Sep 4, 2026
daf58c8
fix(sandbox): keep a linked worktree's .git typed as a file through p…
Vasanthdev2004 Sep 4, 2026
69c68b1
fix(sandbox): stop following sandbox-controlled runtime descendants
Vasanthdev2004 Sep 4, 2026
673b84d
test(sandbox): drive the runtime-tree descent through its entry point
Vasanthdev2004 Sep 4, 2026
9608817
fix(sandbox): restore a raced leaf whose parent this run created
Vasanthdev2004 Sep 4, 2026
4234357
test(sandbox): reproduce the raced leaf at the instant it can actuall…
Vasanthdev2004 Sep 4, 2026
8099409
fix(cli): terminate the sandboxed command when the wrapper is cancelled
Vasanthdev2004 Sep 4, 2026
a186eb4
fix(sandbox): decide the runtime base on a canonical spelling
Vasanthdev2004 Sep 4, 2026
c887961
fix(cli): give a cancelled sandboxed command a graceful phase
Vasanthdev2004 Sep 5, 2026
95c8ac6
fix(sandbox): stop git's global options hiding the subcommand
Vasanthdev2004 Sep 5, 2026
d376b7f
fix(sandbox): refuse git init in a workspace inside another repository
Vasanthdev2004 Sep 5, 2026
9abab73
test(cli): assert the shutdown status and that nothing outlives the run
Vasanthdev2004 Sep 5, 2026
b12b2d7
fix(sandbox): build the elevated secret and runtime directories no-fo…
Vasanthdev2004 Sep 9, 2026
a45d400
fix(sandbox): keep the injectable lstat seam through the carveout specs
Vasanthdev2004 Sep 9, 2026
8b7ffa4
fix(sandbox): treat only a regular .git as the worktree pointer
Vasanthdev2004 Sep 9, 2026
a2e9558
fix(sandbox): remove a failed secret through the handle that created it
Vasanthdev2004 Sep 11, 2026
76d607e
Merge origin/main into feat/windows-sandbox-identity
Vasanthdev2004 Sep 12, 2026
4d3f5dc
fix(sandbox): clone is repository creation too, and the dual-role tes…
Vasanthdev2004 Sep 12, 2026
08005ef
test(sandbox): pin every selectable runtime root to the granted set
Vasanthdev2004 Sep 12, 2026
ecbedb9
fix(sandbox): keep a nested workspace's own git carveouts
Vasanthdev2004 Sep 13, 2026
48f6a88
Merge remote-tracking branch 'origin/main' into HEAD
Vasanthdev2004 Sep 13, 2026
8319484
test(sandbox): compare carveout roots through the profile's own canon…
Vasanthdev2004 Sep 13, 2026
c38fcab
Merge origin/main into feat/windows-sandbox-identity
Vasanthdev2004 Sep 15, 2026
5de72bf
test(sandbox): pin principal mode in the transport tests and give the…
Vasanthdev2004 Sep 15, 2026
926a1eb
fix(sandbox): resolve an inline git alias before deciding it is not init
Vasanthdev2004 Sep 15, 2026
7330a17
fix(sandbox): refuse principal provisioning in the helper, not only i…
Vasanthdev2004 Sep 15, 2026
b9d86ce
test(sandbox): fake the rollback deletion in the mocked fixture; alig…
Vasanthdev2004 Sep 15, 2026
b0ffba6
test(sandbox): own every runtime root a fixture can produce; clean up…
Vasanthdev2004 Sep 15, 2026
b7e97c7
fix(sandbox): validate the runtime root's parent before the lease and…
Vasanthdev2004 Sep 15, 2026
3ef6490
fix(sandbox): bind materialization rollback to the objects it created
Vasanthdev2004 Sep 15, 2026
d3aab2c
fix(sandbox): put a runtime root's grant back when a setup retry fails
Vasanthdev2004 Sep 15, 2026
c753df3
fix(sandbox): create the runtime candidates the unelevated plan grants
Vasanthdev2004 Sep 15, 2026
9b72d0d
test(sandbox): check the replacement survives before reading the roll…
Vasanthdev2004 Sep 15, 2026
6e980a8
test(sandbox): a created chain step now carries the identity rollback…
Vasanthdev2004 Sep 15, 2026
17f55f4
test(sandbox,peermsg): resolve the fixture temp physically so the uni…
Vasanthdev2004 Sep 15, 2026
738881b
Merge remote-tracking branch 'origin/main' into feat/windows-sandbox-…
Vasanthdev2004 Sep 15, 2026
378b38c
fix(sandbox): resolve an inline git alias chain to its end and fail c…
Vasanthdev2004 Sep 15, 2026
56ce081
Merge remote-tracking branch 'origin/main' into feat/windows-sandbox-…
Vasanthdev2004 Sep 18, 2026
df9989e
fix(sandbox): resolve git aliases the way git does before the nested-…
Vasanthdev2004 Sep 18, 2026
281bc65
fix(sandbox): evaluate before planning in sandbox exec, and refuse a …
Vasanthdev2004 Sep 21, 2026
16e30a9
fix(sandbox): keep the git carveouts in a workspace inside another re…
Vasanthdev2004 Sep 22, 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
358 changes: 358 additions & 0 deletions internal/cli/sandbox_exec.go

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions internal/cli/sandbox_exec_cancel_test.go
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)
}
}
162 changes: 162 additions & 0 deletions internal/cli/sandbox_exec_decision_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package cli

import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"

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

// governedNestedWorkspace is a workspace inside somebody else's repository: an
// ancestor carries .git and the workspace itself does not. Setup plans no git
// carveouts for it, which is why a repository must not be created there.
func governedNestedWorkspace(t *testing.T) (ancestor, workspace string) {
t.Helper()
ancestor = t.TempDir()
if err := os.MkdirAll(filepath.Join(ancestor, ".git"), 0o700); err != nil {
t.Fatal(err)
}
workspace = filepath.Join(ancestor, "packages", "app")
if err := os.MkdirAll(filepath.Join(workspace, "sub dir"), 0o700); err != nil {
t.Fatal(err)
}
return ancestor, workspace
}

func sandboxExecTestDeps(workspace string) appDeps {
return appDeps{
getwd: func() (string, error) { return workspace, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
// No native runner. A command that got past the decision would run
// unwrapped, which is exactly what makes a missing refusal visible on
// disk instead of only in a return value.
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{Name: sandbox.BackendUnavailable, Fallback: true, Message: "no native sandbox in this test"}
},
}
}

// `zero sandbox exec` REFUSES WHAT A SESSION WOULD REFUSE.
//
// The command is documented as taking the path a shell tool takes. A shell tool
// is evaluated by the engine and then planned; this went straight to the plan,
// so nothing that lives in Evaluate applied. In a workspace governed by an
// ancestor repository that is the nested-repository guard, and that guard is the
// only thing standing in for the git carveouts setup deliberately does not plan
// there. `zero sandbox exec -- git init` created the repository, writable config
// and hooks included.
//
// Driven through the real CLI entry point, with a backend that would run the
// command unwrapped if it were let through, and checked on disk: the refusal has
// to come with the reason an operator can act on, and no repository may exist
// afterwards. Reported by @jatmn.
func TestSandboxExecRefusesToCreateARepositoryInAGovernedWorkspace(t *testing.T) {
for _, testCase := range []struct {
name string
argv []string
made []string
}{
{name: "git init", argv: []string{"git", "init"}, made: []string{".git"}},
{name: "git init behind -C and a quoted directory", argv: []string{"git", "-C", "sub dir", "init"}, made: []string{"sub dir/.git"}},
{name: "git init behind a shell launcher", argv: []string{"sh", "-c", "git init"}, made: []string{".git"}},
{name: "git init behind env", argv: []string{"env", "GIT_TRACE=0", "git", "init"}, made: []string{".git"}},
{name: "git clone", argv: []string{"git", "clone", "https://example.invalid/repo.git", "vendored"}, made: []string{"vendored"}},
} {
t.Run(testCase.name, func(t *testing.T) {
_, workspace := governedNestedWorkspace(t)
var stdout, stderr bytes.Buffer
args := append([]string{"sandbox", "exec", "--"}, testCase.argv...)
exitCode := runWithDeps(args, &stdout, &stderr, sandboxExecTestDeps(workspace))

for _, made := range testCase.made {
if _, err := os.Lstat(filepath.Join(workspace, filepath.FromSlash(made))); err == nil {
t.Errorf("%s exists after the command: the repository was created under the plain workspace grant", made)
}
}
if exitCode == exitSuccess {
t.Errorf("exit = %d for a command a session refuses; stderr:\n%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "decision=deny") {
t.Errorf("stderr does not report the decision:\n%s", stderr.String())
}
if !strings.Contains(stderr.String(), "sits inside an existing git repository") {
t.Errorf("the refusal does not carry the nested-repository reason and its remedy:\n%s", stderr.String())
}
if strings.Contains(stderr.String(), "backend=") {
t.Errorf("a command plan was built for a refused command:\n%s", stderr.String())
}
})
}
}

// The decision is the engine's, asked the way a session asks it. These are the
// controls: what must NOT be refused, so the gate above is not simply refusing
// git, or refusing this workspace.
func TestSandboxExecDecisionLeavesOrdinaryCommandsAlone(t *testing.T) {
_, governed := governedNestedWorkspace(t)
standalone := t.TempDir()

for _, testCase := range []struct {
name string
workspace string
argv []string
}{
{name: "an ordinary git command in the governed workspace", workspace: governed, argv: []string{"git", "status"}},
{name: "the words git init as one argument", workspace: governed, argv: []string{"echo", "git init"}},
{name: "git init in a workspace nobody governs", workspace: standalone, argv: []string{"git", "init"}},
} {
t.Run(testCase.name, func(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: testCase.workspace,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Fallback: true},
})
decision := sandboxExecDecision(context.Background(), engine, testCase.workspace, testCase.argv)
if decision.Action == sandbox.ActionDeny {
t.Fatalf("%q was refused: %s", strings.Join(testCase.argv, " "), decision.ErrorString())
}
})
}
}

// ARGV BECOMES COMMAND TEXT WITHOUT CHANGING WHAT IT SAYS. The engine classifies
// a shell command by parsing its text, so the rendering decides what the
// classifier sees. One argument has to stay one word, and a payload handed to a
// shell launcher has to arrive as that payload.
func TestSandboxExecCommandTextKeepsArgvIntact(t *testing.T) {
for _, testCase := range []struct {
name string
argv []string
want string
gitInit bool
}{
{name: "bare words stay bare", argv: []string{"git", "init"}, want: "git init", gitInit: true},
{name: "a spaced argument is one word", argv: []string{"echo", "git init"}, want: "echo 'git init'"},
{name: "a launcher payload is analysed as a payload", argv: []string{"sh", "-c", "git init"}, want: "sh -c 'git init'", gitInit: true},
{name: "a quote inside an argument", argv: []string{"printf", "it's"}, want: `printf 'it'\''s'`},
{name: "substitution syntax is inert", argv: []string{"echo", "$(git init)"}, want: "echo '$(git init)'"},
{name: "an empty argument survives", argv: []string{"printf", ""}, want: "printf ''"},
{name: "a directory with a space", argv: []string{"git", "-C", "sub dir", "init"}, want: "git -C 'sub dir' init", gitInit: true},
} {
t.Run(testCase.name, func(t *testing.T) {
text := sandboxExecCommandText(testCase.argv)
if text != testCase.want {
t.Fatalf("command text = %q, want %q", text, testCase.want)
}
analysis := sandbox.AnalyzeCommand(text)
if analysis.TooComplex {
t.Fatalf("%q does not parse, so the classifier would treat a plain argv as obfuscated", text)
}
if analysis.GitInit != testCase.gitInit {
t.Errorf("%q: GitInit = %v, want %v", text, analysis.GitInit, testCase.gitInit)
}
})
}
}
72 changes: 72 additions & 0 deletions internal/cli/sandbox_exec_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cli

import (
"context"
"os"
"runtime"
"strings"
"testing"

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

// envPrinterPlan returns a plan whose child prints its own environment, so the
// assertion is about what the child received rather than about a field value.
func envPrinterPlan(t *testing.T, env []string) zeroSandbox.CommandPlan {
t.Helper()
plan := zeroSandbox.CommandPlan{Dir: t.TempDir(), Env: env}
if runtime.GOOS == "windows" {
plan.Name = "cmd.exe"
plan.Args = []string{"/c", "set"}
return plan
}
plan.Name = "/bin/sh"
plan.Args = []string{"-c", "env"}
return plan
}

// A PLAN THAT SPECIFIES NO VARIABLES MUST NOT INHERIT EVERY VARIABLE.
//
// exec.Cmd reads 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 its length collapsed the 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. Pinned anyway,
// because the next caller that hands the plan a deliberately narrow environment
// would silently inherit everything instead.
func TestAnEmptyPlannedEnvironmentDoesNotInherit(t *testing.T) {
const marker = "ZZ_SANDBOX_ENV_MARKER"
t.Setenv(marker, "leaked-value")

var out strings.Builder
// Specified, and deliberately empty.
code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, []string{}), &out, os.Stderr)
if code != 0 {
t.Fatalf("child exited %d: %s", code, out.String())
}
if strings.Contains(out.String(), marker) {
t.Fatalf("a plan specifying no environment leaked the caller's %s to the child:\n%s", marker, out.String())
}
}

// And a nil environment still inherits, which is the meaning the planner relies
// on when it did not build one. Without this the fix above could be "never pass
// the environment", which would break every ordinary command.
func TestANilPlannedEnvironmentStillInherits(t *testing.T) {
const marker = "ZZ_SANDBOX_ENV_MARKER"
t.Setenv(marker, "inherited-value")

var out strings.Builder
code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, nil), &out, os.Stderr)
if code != 0 {
t.Fatalf("child exited %d: %s", code, out.String())
}
if !strings.Contains(out.String(), marker) {
t.Fatalf("a nil planned environment stopped inheriting, which changes the meaning the planner relies on:\n%s", out.String())
}
}
Loading
Loading