Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
082f032
fix(daemon): publish status files atomically
gnanam1990 Aug 24, 2026
8d0f918
fix(daemon): bind status publication to directory handle
gnanam1990 Aug 24, 2026
c24a634
fix(daemon): validate status directory access
gnanam1990 Aug 24, 2026
f0103fd
fix(daemon): accept current token directory owner
gnanam1990 Aug 24, 2026
b0c83e1
fix(daemon): migrate owned runtime directories safely
gnanam1990 Aug 25, 2026
f314833
fix(daemon): open Windows security handle relatively
gnanam1990 Aug 25, 2026
bfbf7d2
fix(daemon): use current NT directory object
gnanam1990 Aug 25, 2026
33ada0f
fix(observability): harden existing crash directories
gnanam1990 Aug 25, 2026
2ff6b07
fix(security): bind crash report creation to private root
gnanam1990 Aug 28, 2026
6e7f9c4
fix(observability): atomically publish crash reports
gnanam1990 Aug 28, 2026
8549347
fix(observability): preserve committed crash reports
gnanam1990 Aug 28, 2026
ca19e2f
fix(observability): revalidate committed crash path
gnanam1990 Aug 28, 2026
f764ff0
Merge remote-tracking branch 'origin/main' into codex/pr949-followup
gnanam1990 Aug 29, 2026
a4c3cc9
fix(daemon): secure runtime lifecycle boundaries
gnanam1990 Aug 29, 2026
7eb6421
fix(daemon): bind status ownership through cleanup
gnanam1990 Aug 30, 2026
a7a4224
fix(daemon): preserve Windows status security
gnanam1990 Aug 31, 2026
ed2af6e
fix(daemon): retain trusted runtime root for lifecycle
gnanam1990 Aug 31, 2026
ff2ffe3
fix(daemon): roll back redirected socket binds
gnanam1990 Sep 1, 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
7 changes: 1 addition & 6 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -147,11 +146,7 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
Expand Down
14 changes: 12 additions & 2 deletions internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ type ServerOptions struct {
Now func() time.Time
Log func(string)
isAlive func(int) bool // test hook for the single-instance lock
// beforeStatusReplace, replaceStatusFile, and syncStatusParent are test hooks
// for the status-file commit boundary. nil selects production behavior.
beforeStatusReplace func()
replaceStatusFile func(root *os.Root, src, dst string) error
syncStatusParent func(root *os.Root) error
}

// NewServer validates options and builds a Server.
Expand Down Expand Up @@ -80,7 +85,7 @@ func (s *Server) Serve() error {
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
return err
}
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
if err := secureRuntimeParents(s.opts.Paths); err != nil {
return err
}
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
Expand Down Expand Up @@ -208,7 +213,12 @@ func (s *Server) writeStatusFile() error {
if err != nil {
return err
}
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
if err := writeStatusFileAtomically(s.opts.Paths.Status, data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent); err != nil {
var committed *statusFileCommittedError
if errors.As(err, &committed) {
s.logf("daemon: %v", committed)
return nil
}
return fmt.Errorf("daemon: write status file: %w", err)
}
return nil
Expand Down
161 changes: 160 additions & 1 deletion internal/daemon/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,119 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"time"

"github.com/Gitlawb/zero/internal/observability"
)

func TestSecureRuntimeParentsLeaveCustomDirectoryPermissionsUntouched(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permission regression")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
paths := Paths{
Socket: filepath.Join(dir, "daemon.sock"),
Lock: filepath.Join(dir, "daemon.lock"),
Status: filepath.Join(dir, "daemon.status"),
}
if err := secureRuntimeParents(paths); err != nil {
t.Fatalf("secureRuntimeParents: %v", err)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o755 {
t.Fatalf("custom directory permissions = %04o, want unchanged 0755", got)
}
}

func TestSecureRuntimeParentsLeaveRelativeWorkingDirectoryPermissionsUntouched(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permission regression")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
oldWorkingDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWorkingDirectory) })

if err := secureRuntimeParents(Paths{Socket: "daemon.sock", Lock: "daemon.lock", Status: "daemon.status"}); err != nil {
t.Fatalf("secureRuntimeParents: %v", err)
}
info, err := os.Stat(".")
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o755 {
t.Fatalf("working directory permissions = %04o, want unchanged 0755", got)
}
}

func TestOpenRuntimeLogHardensDefaultRootBeforeOpen(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows DACL hardening has platform-specific coverage")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
paths, err := DefaultPaths()
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(paths.Socket)
if err := os.MkdirAll(dir, 0o777); err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatal(err)
}
file, logPath, err := OpenRuntimeLog(paths)
if err != nil {
t.Fatalf("OpenRuntimeLog: %v", err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if logPath != filepath.Join(dir, "daemon.log") {
t.Fatalf("log path = %q", logPath)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got&0o077 != 0 {
t.Fatalf("default runtime directory permissions = %04o, want owner-only", got)
}
}

func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
t.Helper()
dir := t.TempDir()
secureStatusTestDir(t, dir)
paths := Paths{
Socket: filepath.Join(dir, "d.sock"),
Lock: filepath.Join(dir, "d.lock"),
Status: filepath.Join(dir, "d.status"),
}
return newTestServerWithPaths(t, launcher, paths), paths
}

func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Server {
t.Helper()
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
if err != nil {
t.Fatalf("NewPool: %v", err)
Expand All @@ -28,7 +129,7 @@ func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return srv, paths
return srv
}

func waitForFile(t *testing.T, path string) {
Expand Down Expand Up @@ -136,6 +237,64 @@ func TestServerEndToEnd(t *testing.T) {
}
}

func TestServerPublishesDefaultStatusAfterCrashReportCreatesRuntimeDirectory(t *testing.T) {
home, err := os.MkdirTemp("", "zero-home-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(home) })
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")

if _, err := observability.WriteCrashReport(
observability.DefaultCrashDir(),
"cli",
"boom",
[]byte("stack"),
time.Now(),
); err != nil {
t.Fatalf("WriteCrashReport: %v", err)
}
paths, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if paths.Status != filepath.Join(home, ".zero", "daemon.status") {
t.Fatalf("default status path = %q, want path beneath temporary home", paths.Status)
}

launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()

deadline := time.NewTimer(3 * time.Second)
defer deadline.Stop()
for {
if _, err := os.Stat(paths.Status); err == nil {
break
}
select {
case err := <-serveErr:
t.Fatalf("Serve returned before publishing status: %v", err)
case <-deadline.C:
t.Fatal("daemon did not publish its default status file")
case <-time.After(2 * time.Millisecond):
}
}

srv.Shutdown()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve returned error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
}

func TestServerSecondInstanceFails(t *testing.T) {
block := make(chan struct{})
defer close(block)
Expand Down
Loading
Loading