Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fd1efaa
fix(sandbox): protect daemon token file
PierrunoYT Aug 10, 2026
8b1c617
fix(sandbox): close daemon token review gaps
ampagent Aug 11, 2026
cdfacd7
fix(sandbox): reject linkable macOS daemon tokens
PierrunoYT Aug 12, 2026
03c6ab5
fix(sandbox): close the Linux mandatory-token validate/build gap
PierrunoYT Aug 12, 2026
5d69f15
fix(sandbox): fail closed on mandatory symlinks
PierrunoYT Aug 13, 2026
2243918
fix(sandbox): close the Linux token hard-link alias path
PierrunoYT Aug 14, 2026
74ea6af
fix(sandbox): gate path args on exact bytes and close the engine-less…
PierrunoYT Aug 17, 2026
bbce40c
fix(sandbox): complete daemon token boundary
ampagent Aug 18, 2026
af7a3cc
test(sandbox): scope Seatbelt token denial to macOS
PierrunoYT Aug 19, 2026
01538a8
fix(sandbox): reject pre-existing macOS token aliases and pin the cas…
PierrunoYT Aug 21, 2026
b9b6de8
fix(sandbox): preserve daemon token identity
PierrunoYT Aug 22, 2026
476d706
fix(sandbox): address CodeRabbit review findings
PierrunoYT Aug 23, 2026
df7d1df
fix(sandbox): answer an uncreated write root from its parent filesystem
PierrunoYT Aug 23, 2026
6e716f0
fix(sandbox): mandate credential protection independent of the sandbo…
PierrunoYT Aug 23, 2026
6150070
fix(sandbox): close protected token file races
PierrunoYT Aug 24, 2026
52e53a8
Merge branch 'main' into agent/protect-daemon-token-file
PierrunoYT Aug 25, 2026
d2e41d6
fix(tools): bind grep's credential decision to the opened file
PierrunoYT Aug 25, 2026
0f0bb00
Merge remote-tracking branch 'upstream/main' into pr-685
PierrunoYT Aug 26, 2026
278c9a7
fix(tools): apply a patch to the exact path its authorization saw
PierrunoYT Aug 27, 2026
71e9517
fix(sandbox): align patch authorization with execution
PierrunoYT Sep 2, 2026
549f520
Merge remote-tracking branch 'upstream/main' into agent/protect-daemo…
PierrunoYT Sep 7, 2026
682e23f
fix(sandbox): close daemon token review gaps
PierrunoYT Sep 7, 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
24 changes: 22 additions & 2 deletions internal/agent/file_diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@ package agent

import (
"context"
"os"
"io"
"path/filepath"
"strings"
"time"

"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/tools"
)

// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
// slow or wedged language server can never hang a tool call; on timeout the
// edit simply reports without a diagnostics block.
const fileDiagnosticsTimeout = 10 * time.Second

type fileDiagnosticsChecker interface {
Check(context.Context, string, string) ([]lsp.Diagnostic, error)
}

// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
// checks it against the file's language server, and formats error-severity
Expand All @@ -26,14 +31,29 @@ const fileDiagnosticsTimeout = 10 * time.Second
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
// entirely.
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
if manager == nil {
return nil
}
return newFileDiagnostics(manager, workspaceRoot)
}

// newFileDiagnostics accepts the narrow operation used here so race tests can
// observe exactly what would be sent to LSP without installing process-global
// hooks or starting a language server.
func newFileDiagnostics(manager fileDiagnosticsChecker, workspaceRoot string) func(context.Context, string) string {
if manager == nil {
return nil
}
return func(ctx context.Context, absPath string) string {
text, err := os.ReadFile(absPath)
file, _, err := tools.ProtectedReadOpen(absPath, workspaceRoot)
if err != nil {
return ""
}
text, readErr := io.ReadAll(file)
closeErr := file.Close()
if readErr != nil || closeErr != nil {
return ""
}
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
defer cancel()
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
Expand Down
81 changes: 81 additions & 0 deletions internal/agent/file_diagnostics_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
package agent

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

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

type recordingDiagnosticsChecker struct {
called bool
text string
}

func (checker *recordingDiagnosticsChecker) Check(_ context.Context, _ string, text string) ([]lsp.Diagnostic, error) {
checker.called = true
checker.text = text
return nil, nil
}

// Diagnostics are model-facing: absolute paths would leak the local username
// and directory layout into the prompt and session transcript on every edit.
func TestDiagnosticsDisplayPath(t *testing.T) {
Expand All @@ -22,3 +38,68 @@ func TestDiagnosticsDisplayPath(t *testing.T) {
}
}
}

func TestFileDiagnosticsSwapDoesNotSendTokenToLSP(t *testing.T) {
for _, aliasKind := range []string{"symlink", "hardlink"} {
t.Run(aliasKind, func(t *testing.T) {
dir := t.TempDir()
token := filepath.Join(dir, "bridge-token")
target := filepath.Join(dir, "ordinary.go")
const secret = "diagnostics-swap-secret"
if err := os.WriteFile(token, []byte(secret), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte("package ordinary\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "")
t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token)
t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "")

// This swap occurs after the mutating tool would have completed its
// rooted write and immediately before its diagnostics callback reads.
if err := os.Remove(target); err != nil {
t.Fatal(err)
}
var err error
if aliasKind == "symlink" {
err = os.Symlink(token, target)
} else {
err = os.Link(token, target)
}
if err != nil {
t.Skipf("%s unavailable: %v", aliasKind, err)
}
checker := &recordingDiagnosticsChecker{}
output := newFileDiagnostics(checker, dir)(context.Background(), target)
if checker.called || strings.Contains(checker.text, secret) || strings.Contains(output, secret) {
t.Fatalf("token reached LSP/output: called=%v text=%q output=%q", checker.called, checker.text, output)
}
got, readErr := os.ReadFile(token)
if readErr != nil || string(got) != secret {
t.Fatalf("token changed: content=%q err=%v", got, readErr)
}
})
}
}

func TestFileDiagnosticsOrdinaryPositiveControlReachesLSP(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "ordinary.go")
token := filepath.Join(dir, "bridge-token")
const ordinary = "package ordinary\n"
if err := os.WriteFile(target, []byte(ordinary), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(token, []byte("positive-control-secret"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_DAEMON_REMOTE_TOKEN", "")
t.Setenv("ZERO_DAEMON_REMOTE_TOKEN_FILE", token)
t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "")
checker := &recordingDiagnosticsChecker{}
_ = newFileDiagnostics(checker, dir)(context.Background(), target)
if !checker.called || checker.text != ordinary {
t.Fatalf("ordinary diagnostics input: called=%v text=%q", checker.called, checker.text)
}
}
5 changes: 5 additions & 0 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -2183,6 +2183,10 @@ func toAgentAskUserQuestions(questions []tools.AskUserQuestion) []AskUserQuestio

func sandboxRequest(toolName string, tool tools.Tool, args map[string]any, permissionGranted bool, permissionMode PermissionMode, options Options) sandbox.Request {
safety := tool.Safety()
var patchPaths []string
if toolName == "apply_patch" {
patchPaths = tools.ApplyPatchPaths(args)
}
return sandbox.Request{
WorkspaceRoot: "",
ToolName: toolName,
Expand All @@ -2191,6 +2195,7 @@ func sandboxRequest(toolName string, tool tools.Tool, args map[string]any, permi
PermissionGranted: permissionGranted,
PermissionMode: sandbox.PermissionMode(permissionMode),
Args: args,
PatchPaths: patchPaths,
Reason: safety.Reason,
}
}
Expand Down
32 changes: 32 additions & 0 deletions internal/agent/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,38 @@ type mockProvider struct {
requests []zeroruntime.CompletionRequest
}

func TestSandboxRequestApplyPatchPreflight(t *testing.T) {
root := t.TempDir()
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: sandbox.DefaultPolicy()})
tool := tools.NewScopedApplyPatchTool(root, nil)
for _, tc := range []struct {
path string
want sandbox.Action
}{
{"notes.txt", sandbox.ActionAllow},
{".agents/notes.md", sandbox.ActionPrompt},
} {
t.Run(tc.path, func(t *testing.T) {
args := map[string]any{"patch": "--- /dev/null\n+++ b/" + tc.path + "\n@@ -0,0 +1 @@\n+hello\n"}
request := sandboxRequest("apply_patch", tool, args, false, PermissionModeAsk, Options{})
decision := engine.Evaluate(context.Background(), request)
if decision.Action != tc.want {
t.Fatalf("agent preflight = %#v, want %s", decision, tc.want)
}
if !reflect.DeepEqual(request.PatchPaths, []string{tc.path}) {
t.Fatalf("risk classification paths = %q, want %q", request.PatchPaths, tc.path)
}
if tc.want == sandbox.ActionPrompt && !shouldRequestPermission(tool, args, false, &decision) {
t.Fatal("protected metadata must offer approval")
}
})
}
request := sandboxRequest("apply_patch", tool, map[string]any{"patch": "not a patch"}, false, PermissionModeAsk, Options{})
if decision := engine.Evaluate(context.Background(), request); decision.Action != sandbox.ActionDeny {
t.Fatalf("malformed patch preflight = %#v, want deny", decision)
}
}

func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
call := ToolCall{Name: tools.ExecCommandToolName}
Expand Down
22 changes: 15 additions & 7 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ 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.
macOS shell commands require the inline token because
Seatbelt cannot deny inode aliases. Linux accepts a token
file only when it has no hard-link aliases and no
shell-writable root shares its filesystem.
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 @@ -519,11 +524,10 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
token, err := remote.TokenFromEnv()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
auth, err := remote.NewTokenAuthenticator(token)
// Carry both the configured absolute spelling and the resolved startup object
// before workers inherit the environment. The configured identity reserves the
// authority boundary across restart; the resolved identity protects this run.
auth, err := remote.NewAuthenticatorFromEnv()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
Expand Down Expand Up @@ -629,7 +633,9 @@ func runDaemonLink(args []string, stdout io.Writer, stderr io.Writer) int {
return writeExecUsageError(stderr, "daemon link requires --remote, --repo, and --id (or --show <file>)")
}
if strings.TrimSpace(token) == "" {
token, _ = remote.TokenFromEnv() // best effort; UploadRepoBundle rejects an empty token
// A one-shot client that never ran CanonicalizeTokenFileEnv must not trust
// an inherited resolved marker — see TokenFromFreshEnv.
token, _ = remote.TokenFromFreshEnv() // best effort; UploadRepoBundle rejects an empty token
}
link, err := remote.UploadRepoBundle(remote.RemoteConfig{
Address: addr,
Expand Down Expand Up @@ -672,7 +678,9 @@ func dialForCLI(flags remoteDialFlags) (*daemon.Client, error) {
}
token := strings.TrimSpace(flags.Token)
if token == "" {
token, _ = remote.TokenFromEnv() // best effort; DialRemote rejects an empty token
// A one-shot client that never ran CanonicalizeTokenFileEnv must not trust
// an inherited resolved marker — see TokenFromFreshEnv.
token, _ = remote.TokenFromFreshEnv() // best effort; DialRemote rejects an empty token
}
return remote.DialRemote(remote.RemoteConfig{
Address: flags.Addr,
Expand Down
97 changes: 97 additions & 0 deletions internal/cli/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ package cli

import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -36,6 +44,15 @@ func TestDaemonUsage(t *testing.T) {
if code != exitSuccess || !strings.Contains(out, "Usage: zero daemon") {
t.Fatalf("--help exit=%d out=%q", code, out)
}
for _, want := range []string{
"macOS shell commands require the inline token",
"file only when it has no hard-link aliases",
"shell-writable root shares its filesystem",
} {
if !strings.Contains(out, want) {
t.Fatalf("--help does not state %q in the file-token shell contract: %q", want, out)
}
}
}

func TestDaemonUnknownSubcommand(t *testing.T) {
Expand Down Expand Up @@ -209,3 +226,83 @@ func TestDaemonDetachedChildProcess(t *testing.T) {
time.Sleep(time.Hour)
}
}

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")
t.Setenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED", "")

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)
}
configured, err := filepath.Abs(filepath.Join(startDir, "token"))
if err != nil {
t.Fatalf("Abs(token): %v", err)
}
resolved, err := filepath.EvalSymlinks(configured)
if err != nil {
t.Fatalf("EvalSymlinks(%q): %v", configured, err)
}
if got := os.Getenv("ZERO_DAEMON_REMOTE_TOKEN_FILE"); got != configured {
t.Fatalf("ZERO_DAEMON_REMOTE_TOKEN_FILE = %q, want configured path %q", got, configured)
}
if got := os.Getenv("ZERO_INTERNAL_DAEMON_REMOTE_TOKEN_FILE_RESOLVED"); got != resolved {
t.Fatalf("resolved token source = %q, want %q", got, resolved)
}
}

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
}
Loading
Loading