Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
4e1a6b1
feat(tui): add plan mode command and fix plan file editing
euxaristia Jul 10, 2026
cbaff57
feat(tui): add missing internal/planmode package
euxaristia Jul 11, 2026
a8a622a
fix(tui): address review findings on plan mode
euxaristia Jul 11, 2026
e4dbb84
fix(tui): address review findings on plan mode editor and safety
euxaristia Jul 13, 2026
3dc11fe
fix(tui): address plan-mode review findings on toggle, session scopin…
euxaristia Jul 13, 2026
2e7c03f
fix(tui): guard exitPlanMode against clobbering non-plan permission m…
euxaristia Jul 13, 2026
ece9d03
fix(tui): reload plan file into memory on editor exit, preserving sta…
euxaristia Jul 14, 2026
4243005
fix(tui): correct /plan command palette description
euxaristia Jul 14, 2026
9ccc739
fix(tui,agent): address plan-mode review findings on gating, reload, …
euxaristia Jul 14, 2026
2275ad1
fix(tui,planmode): close editor symlink race, propagate edits to cont…
euxaristia Jul 14, 2026
aa03521
fix(planmode,tui): harden editor staging and record cleared plans
euxaristia Jul 15, 2026
52e31ae
fix(planmode,tui,tools): physical staging containment, cancel-safe pl…
euxaristia Jul 15, 2026
432accc
fix(planmode): resolve not-yet-existing paths through their deepest e…
euxaristia Jul 15, 2026
64cb665
fix(tui): confirm plan reload in transcript after editor exit
euxaristia Jul 16, 2026
374d0c8
fix(tui,planmode,tools): close plan-mode gating and durability gaps
euxaristia Jul 18, 2026
c690a3d
fix(agent): suppress executable hooks while plan/spec-draft mode is a…
euxaristia Jul 18, 2026
a516b8c
fix(tui): layer plan-mode prompt instead of replacing it
euxaristia Jul 19, 2026
0544c42
fix(agent): keep trust-gated hooks in spec-draft; harden plan allowlist
euxaristia Jul 19, 2026
3254fc3
fix(planmode,tui): keep durable plans outside the workspace
euxaristia Jul 20, 2026
0c2f8dd
fix(planmode): make Windows honor config-root test isolation and stag…
euxaristia Jul 20, 2026
c72e15b
fix(planmode): make plan storage paths collision-resistant
euxaristia Jul 22, 2026
e1d0f75
fix(tui): preserve active plan file content when entering plan mode
euxaristia Jul 23, 2026
6a07577
fix(tui): address review findings on plan storage workspace path, pla…
euxaristia Jul 23, 2026
1efe0ea
fix(tui): reset plan mode on spec session switch and preserve beforeT…
euxaristia Jul 31, 2026
bc664d0
fix(agent): update tests off tools.CoreTools/NewWriteFileTool removed…
euxaristia Jul 31, 2026
57477e7
fix(tui): address plan mode review (btw switch, testing dep, dead field)
euxaristia Aug 1, 2026
06bbd45
fix(tui): polish plan-mode btw/spec edge cases from review
euxaristia Aug 7, 2026
0c3a812
fix(planmode,tui): address CodeRabbit findings on plan storage and re…
euxaristia Aug 7, 2026
86203e0
fix(planmode,tui,agent): close remaining CodeRabbit findings on plan …
euxaristia Aug 7, 2026
38ac372
fix(planmode): bind ReadPlan containment at open via os.Root
euxaristia Aug 7, 2026
8c00ce6
fix(planmode): refuse final plan symlink without following it
euxaristia Aug 7, 2026
865230f
fix(planmode,tui): close open CodeRabbit findings on plan mode
euxaristia Aug 7, 2026
bead27d
fix(planmode,tui): close remaining CodeRabbit findings on plan mode
euxaristia Aug 7, 2026
81eea9b
fix(planmode,tui): address CodeRabbit review findings and CI assertio…
euxaristia Aug 8, 2026
851b3bc
fix(planmode): harden plan writes and editor roundtrip
euxaristia Aug 10, 2026
e56c0d7
fix(tui): reconcile plan mode with main command semantics
euxaristia Aug 11, 2026
dbea0a8
fix(tui): close remaining plan-mode review findings
euxaristia Aug 11, 2026
2d7349b
Clarify the plan mode entry command
euxaristia Aug 11, 2026
014930c
fix(planmode,tui): close CodeRabbit findings on plan storage and edit…
euxaristia Aug 12, 2026
2199de9
fix(planmode,tui,agent): close remaining CodeRabbit findings on plan …
euxaristia Aug 13, 2026
b66fe18
fix(planmode): recognize a Linux/Darwin ENOTDIR as an intermediate sy…
euxaristia Aug 13, 2026
8a89788
test(tui): pin that /plan on creates its session before naming the pl…
euxaristia Aug 13, 2026
7f2f590
fix(tui): record a plan-file edit only when the plan actually changed
euxaristia Aug 13, 2026
2d266d5
fix(planmode): refuse a symlink or reparse point at the plan storage …
euxaristia Aug 13, 2026
a2f44e5
fix(planmode): Address CodeRabbit review feedback on plan mode and te…
euxaristia Aug 16, 2026
a53545a
fix(planmode,tui): Address review comments on plan reload and staging…
euxaristia Aug 16, 2026
71722eb
Sync peer identity when /plan enters and exits plan mode.
euxaristia Aug 16, 2026
94077a5
Add a Windows junction test for WritePlan storage-root refusal.
euxaristia Aug 16, 2026
bf06e2b
Pause armed loops and goals while plan mode is active.
euxaristia Aug 18, 2026
6e262cf
fix(planmode): address CodeRabbit review feedback on PR #854
euxaristia Aug 20, 2026
670d48b
fix(planmode): sync dirfd on rename and update test assertion message
euxaristia Aug 22, 2026
555d3d5
test(planmode): Pin redirected Windows plan reads.
euxaristia Aug 24, 2026
d2eeccf
fix(tui): Restore session state after plan-mode switches.
euxaristia Aug 24, 2026
c95b000
fix(planmode): resolve Windows junctions when judging staging contain…
euxaristia Aug 24, 2026
7230d33
fix(tui): hold queued prompts while plan mode is active
euxaristia Aug 31, 2026
dec7c6a
Address review feedback on plan persistence, isolation, and staging l…
euxaristia Sep 1, 2026
e315fe3
Fix Unix build imports for plan staging and stale sweep
euxaristia Sep 1, 2026
8c25c35
Tighten Unix staging directory descriptor permissions
euxaristia 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
17 changes: 9 additions & 8 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// the Run turn loop performs the actual provider switch. Empty for every
// ordinary tool result.
RequestedModel: result.Meta["escalate_to_model"],
PlanSnapshot: result.PlanSnapshot,
}, nil
}

Expand Down Expand Up @@ -1867,15 +1868,15 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
}
}

// hooksSuppressed reports whether advisory (non-veto) hooks must not run for
// this run's permission mode. Plan mode promises a read-only turn, but
// sessionStart/sessionEnd/afterTool hooks execute configured host commands
// outside the advertised-tool and sandbox gates, so dispatching them would let
// merely starting a plan session or finishing a read mutate the workspace.
// hooksSuppressed reports whether lifecycle and afterTool hooks must not run
// for this run's permission mode. Plan mode promises a read-only turn, but
// sessionStart, sessionEnd, and afterTool hooks execute configured host
// commands outside the advertised-tool and sandbox gates, so dispatching them
// would let merely starting or finishing a plan session mutate the workspace
// or spawn processes.
//
// beforeTool is intentionally NOT suppressed: a non-zero exit is a deny gate,
// and skipping it fails open (operators who block secret-file reads via
// beforeTool would lose that protection under /plan on). See dispatchBeforeTool.
// beforeTool is intentionally not gated here: fail-closed policy vetoes must
// still apply to read-only plan-mode calls (see dispatchBeforeTool).
//
// Spec-draft keeps the existing trust-gated hook model: project hooks still
// fire when the workspace (or its worktree trust root) is trusted. That is
Expand Down
481 changes: 164 additions & 317 deletions internal/agent/loop_test.go

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions internal/agent/plan_mode_advertised_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package agent

import (
"context"
"testing"

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

// TestToolAdvertisedInPlanExcludesRequestPermissions guards against
// request_permissions leaking into plan mode's read-only allowlist. It is
// classified SideEffectNone + PermissionAllow (control-only, no filesystem or
// network access of its own), but tools.ToolAdvertisedForPermissionMode only
// admits SideEffectRead + PermissionAllow tools (plus no process-spawning
// exceptions) for plan mode. SideEffectNone tools are therefore excluded,
// including request_permissions.
func TestToolAdvertisedInPlanExcludesRequestPermissions(t *testing.T) {
if tools.ToolAdvertisedForPermissionMode(tools.NewRequestPermissionsTool(), tools.PlanMode) {
t.Fatal("request_permissions must not be advertised in plan mode: it would let the model obtain a user-approved permission grant during a supposedly read-only planning turn, which then outlives plan mode")
}
}

// TestRunRejectsRequestPermissionsInPlanMode exercises the same guarantee
// end-to-end: a model that calls request_permissions while PermissionModePlan
// is active gets a dispatch-time rejection, never a permission prompt.
func TestRunRejectsRequestPermissionsInPlanMode(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewRequestPermissionsTool())
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "request_permissions"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"permissions":{"network":true}}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest

result, err := Run(context.Background(), "plan the change", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "unexpected permission request"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(requests) != 0 {
t.Fatalf("expected no permission request while in plan mode, got %#v", requests)
}
if len(provider.requests) < 2 {
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
}
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if lastMessage.ToolCallID != "call-1" {
t.Fatalf("expected tool result message for call-1, got %#v", lastMessage)
}
if want := `Error: Tool "request_permissions" is not available in plan mode.`; lastMessage.Content != want {
t.Fatalf("tool result content = %q, want %q", lastMessage.Content, want)
}
}
31 changes: 31 additions & 0 deletions internal/agent/request_permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,37 @@ func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
}
}

// TestRequestPermissionsDeniedInPlanModeEvenWithoutRegistryEntry guards the
// defense-in-depth check in executeRequestPermissions: the registry-based
// ToolAdvertised gate in executeToolCall only fires when the tool is found in
// whatever registry the caller passed in, but request_permissions is
// dispatched by name regardless of registry contents. A registry that omits
// the tool (e.g. a reduced/specialist registry) must not let a plan-mode turn
// slip through to a real, outliving sandbox grant.
func TestRequestPermissionsDeniedInPlanModeEvenWithoutRegistryEntry(t *testing.T) {
registry := tools.NewRegistry() // deliberately does not register RequestPermissionsTool
promptCalled := false
result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "grant-1",
Name: tools.RequestPermissionsToolName,
Arguments: `{"reason":"try to escape plan mode","permissions":{"file_system":{"write":["/tmp"]}}}`,
}, PermissionModePlan, Options{
OnPermissionRequest: func(_ context.Context, _ PermissionRequest) (PermissionDecision, error) {
promptCalled = true
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
})
if err != nil {
t.Fatal(err)
}
if promptCalled {
t.Fatal("request_permissions must not reach the permission prompt in plan mode, registry entry or not")
}
if result.Status != tools.StatusError || !strings.Contains(result.Output, "not available in plan mode") {
t.Fatalf("result = %#v, want a plan-mode denial error", result)
}
}

func tempDirOutsideDefaultTemp(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
Expand Down
3 changes: 3 additions & 0 deletions internal/agent/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ type ToolResult struct {
// for every normal tool result; the Run loop performs the switch when it is
// set and Options.ModelSwitcher is wired.
RequestedModel string
// PlanSnapshot carries the typed, immutable snapshot of the []PlanItem
// accepted by a successful update_plan call, untampered by transcript scrubbing.
PlanSnapshot []tools.PlanItem `json:"-"`
}

// ModelOutput returns the bounded provider-facing result while preserving
Expand Down
12 changes: 12 additions & 0 deletions internal/planmode/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package planmode

import "testing"

// SetTempDirForTest overrides the temp dir func for unit tests. Kept in
// export_test.go so the production planmode package (and therefore cmd/zero)
// does not import testing.
func SetTempDirForTest(t *testing.T, tempDir string) {
t.Helper()
restore := SetEffectiveTempDirForTest(tempDir)
t.Cleanup(restore)
}
9 changes: 9 additions & 0 deletions internal/planmode/fifo_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !unix

package planmode

import "fmt"

func mkfifoForTest(path string) error {
return fmt.Errorf("mkfifo not available on this platform")
}
9 changes: 9 additions & 0 deletions internal/planmode/fifo_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build unix

package planmode

import "syscall"

func mkfifoForTest(path string) error {
return syscall.Mkfifo(path, 0o600)
}
15 changes: 15 additions & 0 deletions internal/planmode/physical_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build !windows

package planmode

import "path/filepath"

// resolvePhysical returns path with every symlink component resolved. On
// non-Windows systems filepath.EvalSymlinks resolves every link type the
// platform has, so it is the whole implementation.
func resolvePhysical(path string) (string, error) {
return filepath.EvalSymlinks(path)
}

// pathIsReparsePoint is a Windows concept; nothing here reports one.
func pathIsReparsePoint(string) bool { return false }
120 changes: 120 additions & 0 deletions internal/planmode/physical_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//go:build windows

package planmode

import (
"errors"
"fmt"
"path/filepath"
"strings"

"golang.org/x/sys/windows"
)

// resolvePhysical returns path in its canonical physical spelling.
//
// filepath.EvalSymlinks cannot do this alone on Windows. It resolves name
// surrogates (directory symlinks) but not junctions, which os.Lstat reports as
// os.ModeIrregular rather than os.ModeSymlink, so EvalSymlinks hands a junction
// straight back. A junction needs no SeCreateSymbolicLinkPrivilege, so it is
// the reparse point an unprivileged process can actually plant, and treating
// one as its own physical path lets a staging directory that really lands in
// the workspace or the OS temp directory compare as though it sits outside
// both.
//
// GetFinalPathNameByHandle asks the filesystem what the open handle resolved
// to, which is the only answer that accounts for every reparse type at once.
// VOLUME_NAME_DOS also returns long names, so it subsumes the 8.3 short-name
// normalization (RUNNER~1) the caller needs anyway.
func resolvePhysical(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
pathUTF16, err := windows.UTF16PtrFromString(absolute)
if err != nil {
return "", err
}
// FILE_FLAG_BACKUP_SEMANTICS is required to open a directory handle, and
// no reparse flag is passed precisely so the open follows to the target
// this call is asking about.
handle, err := windows.CreateFile(
pathUTF16,
0, // Query the name only; no read or write access is needed.
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return "", err
}
defer windows.CloseHandle(handle)

return finalPathName(handle)
}

// fileNameNormalized|volumeNameDOS is the GetFinalPathNameByHandle flag pair
// that asks for the normalized (long-name) path with a drive letter. Both are
// zero, and x/sys/windows does not export either, so they are named here
// rather than left as a bare literal.
const (
fileNameNormalized = 0x0
volumeNameDOS = 0x0
)

// finalPathName reads the resolved path off an open handle, growing the buffer
// if the path is longer than MAX_PATH (a resolved path can be, which is why
// the API reports the size it needs).
func finalPathName(handle windows.Handle) (string, error) {
buf := make([]uint16, windows.MAX_PATH)
for range 2 {
// On success n excludes the terminating NUL; when the buffer is too
// small n is the required size INCLUDING it, so n >= len(buf) is the
// signal to grow rather than a result.
n, err := windows.GetFinalPathNameByHandle(handle, &buf[0], uint32(len(buf)), fileNameNormalized|volumeNameDOS)
if err != nil {
return "", err
}
if n < uint32(len(buf)) {
return trimExtendedLengthPrefix(windows.UTF16ToString(buf[:n])), nil
}
if n > windows.MAX_LONG_PATH {
return "", fmt.Errorf("resolved path needs %d UTF-16 units, over the %d limit", n, windows.MAX_LONG_PATH)
}
buf = make([]uint16, n)
}
return "", errors.New("resolved path length kept growing between calls")
}

// trimExtendedLengthPrefix converts the extended-length spelling
// GetFinalPathNameByHandle returns back to the ordinary Win32 form, so the
// result compares against paths spelled the way the rest of the process
// spells them. `\\?\UNC\server\share` is a UNC path, not a drive path, and
// has to become `\\server\share` rather than `UNC\server\share`.
func trimExtendedLengthPrefix(path string) string {
if rest, ok := strings.CutPrefix(path, `\\?\UNC\`); ok {
return `\\` + rest
}
if rest, ok := strings.CutPrefix(path, `\\?\`); ok {
return rest
}
return path
}

// pathIsReparsePoint reports whether path itself is a reparse point of any
// kind, junctions included. os.Lstat cannot answer this: it maps a junction to
// os.ModeIrregular, which is indistinguishable from other irregular files, so
// verifyPrivateDirectory's os.ModeSymlink test never fires for one.
func pathIsReparsePoint(path string) bool {
pathUTF16, err := windows.UTF16PtrFromString(path)
if err != nil {
return false
}
attrs, err := windows.GetFileAttributes(pathUTF16)
if err != nil {
return false
}
return attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0
}
Loading
Loading