Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Commands:
daemon. Requires a bearer token in $ZERO_DAEMON_REMOTE_TOKEN
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE). --bundle-dir enables
git-bundle uploads, extracted into per-link work trees.
An inline token takes precedence, so a stale token-file pointer is intentionally not protected as the live credential.
link --remote <host:port> --repo <dir> --id <name> [--out <file>]
Upload repo's git history to the remote as a bundle and
print the extracted remote path. --out saves a session
Expand Down Expand Up @@ -477,6 +478,12 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Pin the token file to the path this process reads BEFORE any worker
// inherits the variable, so a relative or symlinked value cannot make a
// session's sandbox profile protect a different path than the live bearer file.
if err := remote.CanonicalizeTokenFileEnv(); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
token, err := remote.TokenFromEnv()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
Expand Down
84 changes: 84 additions & 0 deletions internal/cli/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@ package cli

import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
Expand Down Expand Up @@ -121,3 +132,76 @@ func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
}
}
}

func TestDaemonServeRemoteCanonicalizesTokenFileBeforeStartingWorkers(t *testing.T) {
isolateDaemonPaths(t)
certFile, keyFile := writeDaemonTestCertificate(t)
startDir := t.TempDir()
t.Chdir(startDir)
if err := os.WriteFile("token", []byte("bridge-token"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "")
t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", "token")

code, _, _ := runDaemonCLI(t, "serve-remote", "--addr", "127.0.0.1:not-a-port", "--tls-cert", certFile, "--tls-key", keyFile)
if code != exitCrash {
t.Fatalf("serve-remote exit = %d, want bind failure", code)
}
want := filepath.Join(startDir, "token")
want, err := filepath.EvalSymlinks(want)
if err != nil {
t.Fatalf("EvalSymlinks(%q): %v", want, err)
}
if got := os.Getenv("ZERO_DAEMON_REMOTE_TOKEN_FILE"); got != want {
t.Fatalf("ZERO_DAEMON_REMOTE_TOKEN_FILE = %q, want daemon-pinned path %q", got, want)
}
}

func writeDaemonTestCertificate(t *testing.T) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "zero-daemon-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
certOut, err := os.Create(certFile)
if err != nil {
t.Fatal(err)
}
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
t.Fatal(err)
}
if err := certOut.Close(); err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
keyOut, err := os.Create(keyFile)
if err != nil {
t.Fatal(err)
}
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
t.Fatal(err)
}
if err := keyOut.Close(); err != nil {
t.Fatal(err)
}
return certFile, keyFile
}
42 changes: 42 additions & 0 deletions internal/daemon/remote/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/Gitlawb/zero/internal/daemon"
Expand Down Expand Up @@ -86,6 +87,47 @@ func TokenFromEnv() (string, error) {
return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile)
}

// CanonicalizeTokenFileEnv rewrites EnvTokenFile in this process's environment
// to the absolute, symlink-resolved path TokenFromEnv actually reads, so every
// child process — and the sandbox profile derived for it — refers to the same
// file this bridge authenticated against. `zero daemon serve-remote` calls it
// before it starts serving.
//
// Two mismatches motivate it. TokenFromEnv passes the value to os.ReadFile, so a
// relative value resolves against the STARTING process's working directory,
// while a worker inherits the same string and resolves it against its own
// session directory — the profile would then protect a path that holds no token
// while the real bearer file stays readable. Resolving symlinks up front also
// keeps a link pathname out of the derived deny rules, which matters because
// bubblewrap cannot mount over a symlink destination.
//
// It is a deliberate no-op when EnvToken supplies the token: TokenFromEnv
// prefers the inline value, so an unused (even dangling) file pointer must not
// change the outcome or fail the start.
func CanonicalizeTokenFileEnv() error {
if strings.TrimSpace(os.Getenv(EnvToken)) != "" {
return nil
}
configured := strings.TrimSpace(os.Getenv(EnvTokenFile))
if configured == "" {
return nil
}
absolute, err := filepath.Abs(configured)
if err != nil {
return fmt.Errorf("remote: resolve token file %q: %w", configured, err)
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
// TokenFromEnv would fail on the same path a moment later; reporting it here
// names the resolution step that failed.
return fmt.Errorf("remote: resolve token file %q: %w", configured, err)
}
if resolved == configured {
return nil
}
return os.Setenv(EnvTokenFile, resolved)
}

// Attestation is an optional post-token hook (e.g. workload attestation). The
// default is a no-op; a deployment can supply a stricter implementation.
type Attestation interface {
Expand Down
84 changes: 84 additions & 0 deletions internal/daemon/remote/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,90 @@ func TestTokenFromEnv(t *testing.T) {
}
}

// TestCanonicalizeTokenFileEnv pins the value every child process (and the
// sandbox profile derived for it) inherits to the file this process reads.
func TestCanonicalizeTokenFileEnv(t *testing.T) {
base, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
token := filepath.Join(base, "tok")
if err := os.WriteFile(token, []byte("from-file\n"), 0o600); err != nil {
t.Fatalf("write token file: %v", err)
}

t.Run("relative value becomes absolute", func(t *testing.T) {
// A worker resolves the inherited value against its own session directory,
// so a relative value must not survive the daemon boundary.
t.Chdir(base)
t.Setenv(EnvToken, "")
t.Setenv(EnvTokenFile, "tok")
if err := CanonicalizeTokenFileEnv(); err != nil {
t.Fatalf("CanonicalizeTokenFileEnv: %v", err)
}
if got := os.Getenv(EnvTokenFile); got != token {
t.Fatalf("%s = %q, want %q", EnvTokenFile, got, token)
}
// Workers run from session directories, so prove the selected path remains
// pinned after crossing that boundary rather than merely inspecting the env.
t.Chdir(t.TempDir())
if tok, err := TokenFromEnv(); err != nil || tok != "from-file" {
t.Fatalf("TokenFromEnv from a worker directory after canonicalization = %q, %v", tok, err)
}
})

t.Run("symlinked pathname is resolved", func(t *testing.T) {
link := filepath.Join(base, "tok-link")
if err := os.Symlink(token, link); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
t.Setenv(EnvToken, "")
t.Setenv(EnvTokenFile, link)
if err := CanonicalizeTokenFileEnv(); err != nil {
t.Fatalf("CanonicalizeTokenFileEnv: %v", err)
}
if got := os.Getenv(EnvTokenFile); got != token {
t.Fatalf("%s = %q, want the resolved target %q", EnvTokenFile, got, token)
}
})

t.Run("an inline token keeps precedence over a dangling pointer", func(t *testing.T) {
// TokenFromEnv prefers EnvToken, so an unused (even dangling) file pointer
// must neither fail the start nor be rewritten.
dangling := filepath.Join(base, "missing", "tok")
t.Setenv(EnvToken, "from-env")
t.Setenv(EnvTokenFile, dangling)
if err := CanonicalizeTokenFileEnv(); err != nil {
t.Fatalf("CanonicalizeTokenFileEnv with an inline token: %v", err)
}
if got := os.Getenv(EnvTokenFile); got != dangling {
t.Fatalf("%s = %q, want it left alone", EnvTokenFile, got)
}
if tok, err := TokenFromEnv(); err != nil || tok != "from-env" {
t.Fatalf("TokenFromEnv = %q, %v, want the inline token", tok, err)
}
})

t.Run("a selected but unreadable pointer fails closed", func(t *testing.T) {
t.Setenv(EnvToken, "")
t.Setenv(EnvTokenFile, filepath.Join(base, "missing", "tok"))
if err := CanonicalizeTokenFileEnv(); err == nil {
t.Fatal("a selected token file that cannot be resolved must error")
}
})

t.Run("no pointer is a no-op", func(t *testing.T) {
t.Setenv(EnvToken, "")
t.Setenv(EnvTokenFile, "")
if err := CanonicalizeTokenFileEnv(); err != nil {
t.Fatalf("CanonicalizeTokenFileEnv without a pointer: %v", err)
}
if got := os.Getenv(EnvTokenFile); got != "" {
t.Fatalf("%s = %q, want empty", EnvTokenFile, got)
}
})
}

func TestServerTLSConfigRequiresCertKey(t *testing.T) {
if _, err := ServerTLSConfig("", ""); err == nil {
t.Fatal("ServerTLSConfig must require a cert and key (TLS mandatory)")
Expand Down
55 changes: 45 additions & 10 deletions internal/sandbox/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,33 +144,43 @@ func (engine *Engine) LookupCommandPrefixForSession(toolName string, command []s
// don't re-run Abs/EvalSymlinks per visited path. Returns nil for a nil engine
// (the matcher's methods treat nil as "exclude nothing").
func (engine *Engine) ReadExclusions() *ReadExclusions {
// A disabled policy enforces nothing, so it must not filter search results
// either (Evaluate already allows every request under ModeDisabled).
// A disabled policy enforces nothing the USER configured, so it must not
// filter search results by DenyRead either (Evaluate likewise allows every
// request under ModeDisabled). The automatic credential exclusion is not
// policy-derived and survives: the bridge token authenticates the channel
// driving these tools, so turning the sandbox off must not hand a remote
// caller its own credential.
if engine == nil {
return nil
}
policy := engine.effectivePolicy(engine.policy)
if policy.Mode == ModeDisabled {
return nil
protected := protectedCredentialPaths()
if len(protected) == 0 {
return nil
}
return &ReadExclusions{workspaceRoot: engine.workspaceRoot, protectedRoots: protected}
}
return &ReadExclusions{
workspaceRoot: engine.workspaceRoot,
denyRoots: resolvePolicyPaths(policy.DenyRead),
allowRoots: resolvePolicyPaths(policy.AllowRead),
workspaceRoot: engine.workspaceRoot,
denyRoots: resolvePolicyPaths(policy.DenyRead),
allowRoots: resolvePolicyPaths(policy.AllowRead),
protectedRoots: protectedCredentialPaths(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

// ReadExclusionGlobs returns the ripgrep-style --glob exclusion args for this
// engine's policy + scope (see the package-level ReadExclusionGlobs). Empty when
// DenyRead is unset or the engine has no scope.
func (engine *Engine) ReadExclusionGlobs() []string {
// A disabled policy filters nothing (parity with ReadExclusions / Evaluate).
// A disabled policy filters nothing the user configured, but keeps the
// automatic credential exclusion (parity with ReadExclusions / Evaluate).
if engine == nil {
return nil
}
policy := engine.effectivePolicy(engine.policy)
if policy.Mode == ModeDisabled {
return nil
return ReadExclusionGlobs(Policy{}, engine.scope)
}
return ReadExclusionGlobs(policy, engine.scope)
}
Expand All @@ -183,7 +193,14 @@ func (engine *Engine) effectiveNetworkMode(policy Policy) NetworkMode {
}

// UnsandboxedExecutionAllowed reports whether an escalated shell attempt may
// bypass the native sandbox without dropping active denied-read restrictions.
// bypass the native sandbox without dropping active denied-read restrictions,
// including the automatic remote bridge token exclusion.
//
// ModeDisabled short-circuits to true before the token is consulted, and that is
// the intended reading of the switch rather than an oversight: with the sandbox
// off there is no wrapper for an escalation to bypass, so refusing the
// escalation would deny a capability the operator already granted globally
// without protecting anything. See the ModeDisabled branch in Evaluate.
func (engine *Engine) UnsandboxedExecutionAllowed() bool {
if engine == nil {
return true
Expand All @@ -192,7 +209,7 @@ func (engine *Engine) UnsandboxedExecutionAllowed() bool {
if policy.Mode == ModeDisabled {
return true
}
return len(normalizeProfilePaths(policy.DenyRead)) == 0
return len(normalizeProfilePaths(policy.DenyRead)) == 0 && len(protectedCredentialPaths()) == 0
}

// toolNetworkExempt reports whether a request is exempt from the engine-level
Expand Down Expand Up @@ -318,6 +335,24 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
risk := classifyWithScope(request, scope)

if policy.Mode == ModeDisabled {
// Disabling the sandbox drops every user-configured restriction, but not the
// automatic credential exclusion: the remote bridge token authenticates the
// caller driving these tools, so it stays unreadable and unwritable through
// them.
//
// Shell is deliberately left open here, and it is worth being blunt about
// why. ModeDisabled means no OS wrapper is built at all — the runner sets
// SandboxPreferenceForbid and PermissionProfileFromPolicy returns an
// unrestricted filesystem — so there is no layer left that could confine a
// command. Re-wrapping shell just for this file would quietly undo the
// switch the operator flipped, and would not hold anyway: a shell that can
// run anything can read anything this process can. The exclusion below is
// therefore what it says — a guarantee about Zero's own file tools, not
// about the machine. Running a remote bridge with the sandbox disabled
// means trusting whoever can drive that bridge with the token.
if block := protectedCredentialPathBlock(request, request.WorkspaceRoot); block != nil {
return deny(request, risk, block.Code, block.Path, block.Reason, false)
}
return Decision{Action: ActionAllow, Risk: risk, Reason: "sandbox disabled"}
}
if request.Permission == PermissionDeny {
Expand Down
Loading
Loading