Skip to content

feat(tui): add plan mode command and fix plan file editing - #854

Closed
euxaristia wants to merge 58 commits into
Gitlawb:mainfrom
euxaristia:feat/tui-plan-mode
Closed

feat(tui): add plan mode command and fix plan file editing#854
euxaristia wants to merge 58 commits into
Gitlawb:mainfrom
euxaristia:feat/tui-plan-mode

Conversation

@euxaristia

@euxaristia euxaristia commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds the /plan command and TUI wiring for PermissionModePlan (see the companion agent-side PR), including a command-palette entry, editor round-trip for the plan file, and status/notes preserved across editor exit
  • Plan storage lives outside the workspace, keyed by a slugified path plus a SHA-256 hash of the exact workspace/session string for collision resistance, and resolves not-yet-existing paths through their deepest existing ancestor so it works before the workspace is fully materialized
  • Hardens the editor round-trip: closes a symlink race during staging, contains staging physically, keeps plan state cancel-safe, and guards exitPlanMode against clobbering an unrelated permission mode
  • Resets plan mode correctly on spec-session switch and preserves beforeTool policy vetoes while active

Test plan

  • go test ./internal/tui/... ./internal/planmode/...

Summary by CodeRabbit

  • New Features

    • Added durable plan files with editing through $VISUAL or $EDITOR.
    • Expanded /plan commands to view, open, enable, disable, and exit plan mode.
    • Plan status, notes, and snapshots persist across sessions and reload when switching sessions.
    • Plan updates synchronize with the plan view and agent state.
  • Bug Fixes

    • Restricted unsafe tools and permission requests during plan mode.
    • Paused automatic continuations and scheduled loops while plan mode is active.
    • Preserved plan state across side conversations, session changes, cancellations, and failed operations.
    • Improved plan-file security and reload error handling.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cffb1d18-f07c-46db-bc90-5c4e388d67c4

📥 Commits

Reviewing files that changed from the base of the PR and between d7ac85c and b065a9f.

📒 Files selected for processing (39)
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/agent/plan_mode_advertised_test.go
  • internal/agent/request_permissions_test.go
  • internal/planmode/export_test.go
  • internal/planmode/fifo_other_test.go
  • internal/planmode/fifo_unix_test.go
  • internal/planmode/planmode.go
  • internal/planmode/planmode_test.go
  • internal/planmode/read.go
  • internal/planmode/read_other.go
  • internal/planmode/read_unix.go
  • internal/planmode/read_windows.go
  • internal/planmode/read_windows_test.go
  • internal/planmode/write.go
  • internal/planmode/write_other.go
  • internal/planmode/write_unix.go
  • internal/planmode/write_windows.go
  • internal/planmode/write_windows_test.go
  • internal/tools/types.go
  • internal/tools/update_plan.go
  • internal/tools/update_plan_test.go
  • internal/tui/btw.go
  • internal/tui/btw_test.go
  • internal/tui/commands.go
  • internal/tui/commands_test.go
  • internal/tui/goal.go
  • internal/tui/goal_test.go
  • internal/tui/loop.go
  • internal/tui/loop_controller_test.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/plan_command.go
  • internal/tui/plan_command_test.go
  • internal/tui/session.go
  • internal/tui/session_test.go
  • internal/tui/spec_mode.go
  • internal/tui/spec_mode_test.go
  • internal/tui/view.go

Included review availability: Your plan provides up to 4 included reviews per hour; 2 remain after this review.


Walkthrough

Plan mode now restricts tool execution, permission requests, executable hooks, and automatic continuations. Plans use secure durable storage and editor staging. The TUI synchronizes plan state across updates, editing, sessions, BTW conversations, and spec transitions.

Changes

Plan mode and storage

Layer / File(s) Summary
Agent restrictions and hook policy
internal/agent/loop.go, internal/agent/*_test.go
Plan mode filters tools, blocks permission requests and spoofed mutating tools, preserves beforeTool vetoes, and suppresses lifecycle and afterTool hooks.
Durable storage and editor staging
internal/planmode/*
Plans use hashed paths, restricted permissions, containment checks, no-follow access, atomic replacement, and validated editor staging across platforms.
Plan tool synchronization
internal/tools/types.go, internal/tools/update_plan.go, internal/tools/update_plan_test.go
Successful updates return snapshots. Cancelled updates do not change stored state. SetPlan copies and normalizes plan data.

TUI plan workflow

Layer / File(s) Summary
Plan commands and persistence
internal/tui/commands.go, internal/tui/model.go, internal/tui/plan_command.go, internal/tui/*_test.go
The TUI supports plan status, entry, editing, exit, parsing, durable persistence, prompt composition, snapshot synchronization, metadata filtering, and panel refreshes.
Session lifecycle and continuation control
internal/tui/btw.go, internal/tui/session.go, internal/tui/spec_mode.go, internal/tui/goal.go, internal/tui/loop.go, internal/tui/*_test.go
Session changes, BTW conversations, and spec transitions reset or restore plan state. Loops and goals pause while plan mode blocks continuations.

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: 🟡 Moderate · up to b065a

This change adds plan mode and plan-file editing, but unresolved platform-specific plan I/O failures, stale permission state for peers, and loops that can remain paused after switching modes create concrete correctness and availability risks. The PR should not merge until these issues are fixed or explicitly accepted by the owners.

Sequence Diagram(s)

sequenceDiagram
  participant Agent
  participant UpdatePlanTool
  participant TUI
  participant PlanStorage
  Agent->>UpdatePlanTool: submit plan update
  UpdatePlanTool->>TUI: return plan snapshot metadata
  TUI->>PlanStorage: persist plan
  TUI->>TUI: refresh plan state and panel
Loading

Possibly related PRs

  • Gitlawb/zero#642: This change extends its PermissionModePlan behavior and modifies related agent gating, hook suppression, TUI handling, and tool-control logic.

Suggested reviewers: anandh8x

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary changes: adding the TUI plan mode command and improving plan file editing.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@euxaristia
euxaristia force-pushed the feat/tui-plan-mode branch from 0708379 to a372f61 Compare July 31, 2026 19:46

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan-mode core here is good. I drove the real advertised-tool gate against the full core registry and plan mode exposes exactly ask_user, glob, grep, list_directory, read_file, read_minified_file, skill, update_plan — every mutator, web_fetch, lsp_navigate, request_permissions, the Task/swarm spawners (SideEffectShell) and all MCP tools (SideEffectNetwork) are denied, at advertisement and at dispatch. The name-only spoofing guard on update_plan/ask_user is the right call, and so is denying request_permissions before the registry lookup rather than relying on the registry to omit it. tea.ExecProcess is used correctly: the m.pending || m.exiting gate keeps it off a live run, the staged copy plus defer cleanup() in the callback is right, and I couldn't find a terminal-state path that escapes bubbletea's release/restore. go build ./..., go vet, gofmt -l, and go test ./internal/tui ./internal/agent ./internal/planmode ./internal/tools are all clean here.

Four things before this goes in.

1. /btw is the session switch you missed. internal/tui/btw.go:94 does side.activeSession = fork without exitPlanMode() or resetPlanForSessionSwitch(). You guard the other four switch sites (session.go:70, session.go:241, spec_mode.go:38, spec_mode.go:203); this is the fifth. Driving the real path — enter plan mode from Ask with an update_plan draft in the tool, then /btw — gives:

  • side.permissionMode == "plan" and side.permissionModeBeforePlan == "ask", so the isolated side conversation is silently read-only and a /plan off inside it restores the main session's prior mode into the fork.
  • side.planText() renders the main session's plan. side.plan.clear() at btw.go:144 only clears the sticky panel; the shared update_plan tool still holds the parent's items.
  • Worse, that leak is now durable: /plan open inside the side conversation seeds the fork's plan file with the parent's plan. I got planmode.ReadPlan(cwd, side.activeSession.SessionID) returning exists=true, "1. [in_progress] MAIN SESSION SECRET STEP\n" for a session that never drafted it.

Add the same two calls at btw.go:94. Two more things while you're in there: leaveBTW (btw.go:164) restores the parent model wholesale but not the shared update_plan tool, which the side conversation may have replaced — it should re-hydrate from the parent session's plan file the way handleResumeCommand now does. And btwCommandUnavailable (btw.go:206) already blocks /new, /resume, /spec, /loop, /goal; /plan now mutates permission mode and writes durable per-session files, so it probably belongs on that list too.

2. internal/planmode drags testing into the shipped binary. planmode.go:12 imports "testing" for SetTempDirForTest (planmode.go:381). go list -deps ./cmd/zero | grep -cx testing is 0 on origin/main and 1 on this branch (it brings flag and regexp along too), and planmode.go is the only non-test file under internal/ that does this. Move the helper to an export_test.go in the package, or to a planmodetest subpackage, and keep tempDirFn unexported.

3. program *tea.Program at model.go:135 is dead. Nothing assigns or reads it — deleting the line and running go build ./internal/tui/ exits 0. The comment says it's "set right before Run", but run.go is untouched by this PR, and plan_command_test.go:210 already refers to it as "(now-removed)". Drop the field and fix that test's rationale comment.

4. hooksSuppressed's comment says something the code doesn't do. loop.go:1791 explains suppression as preventing "merely starting a plan session or calling read_file" from mutating the workspace or spawning processes — but dispatchBeforeTool is deliberately exempt, and beforeTool is precisely the hook that fires on every read_file. I ran a plan-mode Run with a beforeTool hook that shells out to go mod init -modfile <tmp>/go.mod: the audit store logged hook_execution_started/completed for beforeTool, read_file returned normally, and the file existed on disk afterwards. Keeping beforeTool for fail-closed policy vetoes is the right trade-off — just say so in the comment instead of claiming the opposite. Relatedly, TestRunSuppressesExecutableHooksInPlanMode asserts "no hook command at all" but only wires sessionStart/sessionEnd; either soften the wording or add a case that pins the beforeTool exemption, so a future change can't flip it silently.

Smaller things, none blocking:

  • exitPlanMode (plan_command.go:122) falls back to Auto when permissionModeBeforePlan is empty. nextPermissionMode folds unknown modes to Ask on purpose ("the stricter landing") and app.go:786 makes Ask the interactive default — Auto is the looser landing. Only reachable via an embedder starting in plan mode, but make it Ask.
  • handleSpecCommand (spec_mode.go:38) clears plan state before createSpecDraftSession; on a create failure the user loses plan mode and the in-memory plan with no session switch. handleResumeCommand's ordering (switch, then reset) is the shape to copy.
  • result.Meta[plan_snapshot] lands verbatim in the session event log (model.go:5526), so every update_plan stores the plan twice on disk. It isn't replayed into model context, so it's disk-only, but stripping it from toolPayload is cheap.
  • Plan files accumulate under UserConfigDir/zero/plans forever, one per (workspace, session), with no pruning. Worth a retention story.
  • Entering plan mode doesn't pause an armed /goal or /loop, so continuations keep firing turns that can't make progress. Safe, just wasteful.

One thing I checked and am happy with: I fuzzed formatPlanItems/parsePlanFileLines beyond your tests (empty content, leading whitespace on the first line, a first line reading "3. ...", a [weird] leading token, tab continuations, blank note lines, an empty first Notes line, CRLF). Every case is a fixed point under repeated open-and-save; the only losses are leading whitespace on an item's first content line and an empty first Notes line, both harmless. The escape/indent encoding holds up.

Same as the others today: this is everything in one pass, nothing queued behind it. And thanks for the turnaround on #849 — that one went from requested-changes to approved inside two hours, which is the loop I'd like these to run in.

@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed review:

  1. /btw now calls exitPlanMode + resetPlanForSessionSwitch like the other session-switch sites (regression: TestBTWExitsPlanModeOnSideAndPreservesParent).
  2. Moved SetTempDirForTest to export_test.go so testing is not a dep of ./cmd/zero.
  3. Dropped unused program *tea.Program on the model.
  4. Corrected hooksSuppressed docs: advisory hooks only; beforeTool still runs for fail-closed vetoes (TestBeforeToolStillRunsInPlanMode).

Vasanthdev2004
Vasanthdev2004 previously approved these changes Aug 2, 2026

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — all four are done, and I checked each one rather than going off the summary.

The /btw fix is the one I cared about. It now calls exitPlanMode and resetPlanForSessionSwitch like the other four switch sites, so it's no longer the odd one out. I commented out the exitPlanMode call and TestBTWExitsPlanModeOnSideAndPreservesParent fails with "BTW side kept plan mode: plan", so the guard is genuinely held in place rather than just present in the diff. Good that the test also pins the parent side surviving — that's the half that would have been easy to miss.

The testing import is properly gone: go list -deps ./cmd/zero | grep -cx testing is 0 on this head, where it was 1 before. Moving SetTempDirForTest into export_test.go was the cleaner of the two options I suggested.

Dead program field is gone, and the hooksSuppressed comment now says advisory-only with beforeTool still running for fail-closed vetoes — which matches what #853 actually does now, so the two PRs tell the same story. Worth something that they agree; a comment that drifts from its sibling PR is how the original confusion started.

Nothing else from me on this one.

euxaristia added a commit to euxaristia/zero that referenced this pull request Aug 7, 2026
Block /plan inside /btw, re-sync parent plan on leaveBTW, fall back to
Ask when exitPlanMode has no prior mode, clear plan only after successful
/spec session create, and omit plan_snapshot from session tool events.

Refs Gitlawb#854
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed the remaining plan-mode edge cases on tip f03e2273:

  1. /plan blocked inside /btw — added commandPlan to btwCommandUnavailable.
  2. leaveBTW re-syncs shared plan — reloads parent session plan file into the sticky panel and shared update_plan tool (same shape as /resume).
  3. exitPlanMode empty prior → Ask — no longer falls back to Auto.
  4. /spec create failure preserves plan mode — reset/exit only after successful session create.
  5. Session tool events omit plan_snapshot — durable plan file remains the source of truth.

Regression tests cover each item; they fail on the previous tip and pass here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (7)
internal/tools/update_plan.go (1)

108-117: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Copy the slice in SetPlan to match CurrentPlan.

SetPlan stores the caller's slice directly. enforceSingleInProgress also mutates that slice in place when more than one item has status in_progress. Two consequences follow:

  1. The caller's slice is modified as a side effect of calling SetPlan.
  2. The tool and the caller then share one backing array, so a later caller mutation changes tool state without the mutex.

CurrentPlan already returns a copy, so the boundary is inconsistent. Callers do retain the slice: internal/tui/btw_test.go passes items to SetPlan and then reuses items for the plan panel.

♻️ Proposed fix
 func (tool *updatePlanTool) SetPlan(plan []PlanItem) {
-	plan = enforceSingleInProgress(plan)
+	// Copy before normalizing: enforceSingleInProgress mutates in place, and
+	// the tool must not share a backing array with the caller (CurrentPlan
+	// returns a copy for the same reason).
+	plan = enforceSingleInProgress(append([]PlanItem(nil), plan...))
 	tool.mu.Lock()
 	tool.currentPlan = plan
 	tool.mu.Unlock()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/update_plan.go` around lines 108 - 117, Update
updatePlanTool.SetPlan to copy the incoming plan slice before enforcing statuses
and storing it, ensuring the tool owns its backing array and caller mutations
cannot affect currentPlan. Preserve the existing enforceSingleInProgress
behavior while making the stored plan consistent with CurrentPlan’s
copy-on-boundary behavior.
internal/planmode/planmode.go (1)

204-218: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Use handle-relative staging for StageForEditor.

StageForEditor still resolves with filepath.EvalSymlinks, validates resolvedDir, then opens with stageContentForEditor(resolvedDir, ...). That is pre-open resolution followed by open, which the code guidelines reject. With the declared Go toolchain, open the staging parent with os.OpenRoot and use the os.Root methods for Chmod and CreateTemp so containment is bound at open/use time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/planmode/planmode.go` around lines 204 - 218, Update StageForEditor
to avoid filepath.EvalSymlinks and path-based staging; open the staging parent
with os.OpenRoot, then use the resulting os.Root methods for Chmod and
CreateTemp so validation and file creation remain handle-relative. Adapt
stageContentForEditor to accept and use the root handle, while preserving the
existing privacy checks and error behavior.

Source: Coding guidelines

internal/agent/request_permissions_test.go (1)

149-151: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Also assert the denial category.

executeRequestPermissions sets DenialReason: DenialFiltered on the plan-mode denial. Surfaces branch on that category instead of parsing Output. Pin it here so a future change cannot drop the field while keeping the message.

💚 Proposed assertion
 	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)
 	}
+	if result.DenialReason != DenialFiltered {
+		t.Fatalf("DenialReason = %q, want %q", result.DenialReason, DenialFiltered)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/request_permissions_test.go` around lines 149 - 151, Update
the assertion for executeRequestPermissions’ plan-mode denial to also require
the result’s DenialReason to equal DenialFiltered, while preserving the existing
status and output checks.
internal/agent/loop_test.go (2)

3448-3463: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The comment claims ask_user coverage, but only update_plan is exercised.

Loop over both names, or narrow the comment to update_plan. A table subtest keeps the guard honest if someone later reintroduces a name-based allowlist for ask_user.

♻️ Table-driven variant
-func TestPlanModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
-	root := t.TempDir()
-	written := filepath.Join(root, "spoofed.txt")
-	registry := tools.NewRegistry()
-	registry.Register(spoofedSafetyTool{
-		name:   "update_plan",
+func TestPlanModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
+	for _, spoofed := range []string{"update_plan", "ask_user"} {
+		t.Run(spoofed, func(t *testing.T) {
+			runSpoofedControlToolCase(t, spoofed)
+		})
+	}
+}
+
+func runSpoofedControlToolCase(t *testing.T, toolName string) {
+	t.Helper()
+	root := t.TempDir()
+	written := filepath.Join(root, "spoofed.txt")
+	registry := tools.NewRegistry()
+	registry.Register(spoofedSafetyTool{
+		name:   toolName,
 		safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "spoofed"},

Then thread toolName through the provider events and the advertisement assertion.

As per coding guidelines: "Ensure PR descriptions, help text, and comments match shipped behavior".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop_test.go` around lines 3448 - 3463, Update
TestPlanModeRejectsNameOnlySpoofedControlTools to cover both “update_plan” and
“ask_user” as claimed, preferably with table-driven subtests, and thread each
toolName through provider events and advertisement assertions. Alternatively,
narrow the test comment to describe only the currently exercised “update_plan”
case.

Source: Coding guidelines


4035-4074: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a filesystem state change that actually covers the hook, not the failure path.

go mod init -modfile <marker>/go.mod marker exits when marker does not exist and does not create marker, so the os.Stat(marker) check only guards the failed command. Use a temp directory that exists and have the hook create a file in it if executed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop_test.go` around lines 4035 - 4074, Update the test around
the dispatcher and marker setup so the hook’s command operates on an
already-created temporary directory and creates a file inside it when executed.
Change the final filesystem assertion to check that file remains absent,
ensuring the test detects actual hook execution rather than only a failed go
command.

Source: Coding guidelines

internal/tui/plan_command_test.go (2)

148-170: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add a regression test for the unknown-/plan-subcommand guard.

handlePlanCommand treats an unrecognized subcommand as a hard error specifically so it cannot fall through to the bare toggle. The comment at internal/tui/plan_command.go Lines 55-58 states the reason: falling through "would silently exit the read-only boundary and re-enable implementation."

That is a security-boundary behavior with no test here. TestBarePlanTogglesOff covers the toggle, but nothing covers /plan openx or /plan status while plan mode is active.

As per coding guidelines: "Every behavior or security-boundary change requires a regression test, including failure paths."

🧪 Proposed test
func TestUnknownPlanSubcommandDoesNotExitPlanMode(t *testing.T) {
	// Regression: an unrecognized subcommand must not fall through to the
	// bare /plan toggle, which would silently drop the read-only boundary.
	m := newPlanModeTestModel(t, t.TempDir(), agent.PermissionModePlan)
	m.permissionModeBeforePlan = agent.PermissionModeAsk

	for _, arg := range []string{"openx", "status", "on"} {
		updated, cmd := m.handlePlanCommand(arg)
		next := updated.(model)
		if cmd != nil {
			t.Fatalf("%q: expected no command", arg)
		}
		if next.permissionMode != agent.PermissionModePlan {
			t.Fatalf("%q: expected plan mode preserved, got %s", arg, next.permissionMode)
		}
		if !transcriptContains(next.transcript, "Unknown /plan subcommand") {
			t.Fatalf("%q: expected an unknown-subcommand error, got %#v", arg, next.transcript)
		}
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/plan_command_test.go` around lines 148 - 170, Add a regression
test alongside TestBarePlanTogglesOff for unknown handlePlanCommand subcommands
such as “openx”, “status”, and “on” while PermissionModePlan is active. Assert
no command is returned, plan mode remains active, and the transcript contains
the “Unknown /plan subcommand” error, ensuring invalid input cannot fall through
to the bare toggle.

Source: Coding guidelines


328-337: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Canonicalize both paths before the containment assertion.

Line 332 compares the raw path and cwd spellings. On macOS t.TempDir() returns a /var/folders/... path that is a symlink to /private/var/folders/.... If the durable plan path ever resolved through the other spelling, this prefix check would pass while the file actually sits inside the workspace. The assertion is guarding a security boundary, so it must not be defeatable by a path-spelling difference.

As per coding guidelines: "canonicalize paths before comparison and avoid asserting raw temporary-directory spellings."

🧭 Proposed fix: resolve symlinks before comparing
 	path, err := planmode.PlanFilePath(cwd, next.activeSession.SessionID)
 	if err != nil {
 		t.Fatalf("PlanFilePath: %v", err)
 	}
-	if strings.HasPrefix(path, cwd+string(os.PathSeparator)) || path == cwd {
-		t.Fatalf("durable plan path %q must not live under the workspace %q", path, cwd)
+	resolvedCwd, err := filepath.EvalSymlinks(cwd)
+	if err != nil {
+		t.Fatalf("EvalSymlinks(cwd): %v", err)
+	}
+	// The plan file's parent exists even when the leaf may not; resolve the dir.
+	resolvedPlanDir, err := filepath.EvalSymlinks(filepath.Dir(path))
+	if err != nil {
+		t.Fatalf("EvalSymlinks(plan dir): %v", err)
+	}
+	resolvedPlan := filepath.Join(resolvedPlanDir, filepath.Base(path))
+	if rel, err := filepath.Rel(resolvedCwd, resolvedPlan); err == nil &&
+		rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+		t.Fatalf("durable plan path %q must not live under the workspace %q", resolvedPlan, resolvedCwd)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/plan_command_test.go` around lines 328 - 337, Canonicalize both
path values before the containment assertion in the plan path test: resolve
symlinks for cwd and the value returned by planmode.PlanFilePath, handle
resolution errors through the test, then perform the existing workspace-prefix
and equality checks on the canonical paths. Keep the .zero absence assertion
unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/planmode/planmode_test.go`:
- Around line 496-520: Extend the planmode tests with direct StageForEditor
coverage: configure the user config root to the workspace and assert it returns
the containment error, then add a success case verifying the staged file is
created under the resolved config staging directory. Reuse the existing test
setup and symbols such as StageForEditor and the config-root mechanism, while
retaining the platform-specific permission skips where applicable.
- Around line 295-304: Update TestWritePlanRejectsStorageInsideWorkspace to
override the plan storage temp-directory provider via SetTempDirForTest with an
unrelated directory, preventing the global temp-directory containment check from
triggering. Keep the workspace as the configured user config root, and assert
that WritePlan returns the expected workspace-containment error text so the test
specifically validates that rule.

In `@internal/planmode/planmode.go`:
- Around line 260-269: Update editorStagingDirIsPrivate so a
filepath.Abs(workspaceRoot) error immediately returns false instead of skipping
the workspace containment check and returning true; preserve the existing
rejection for directories under the resolved workspace root and temp directory.
- Around line 125-131: Update the comment above tmpPath in the plan-writing flow
to remove the inaccurate “random suffix” claim and describe the
PID/timestamp-based name accurately; retain the explanation that O_EXCL rejects
existing or pre-planted paths. Do not change the temporary-file implementation
unless needed to keep the comment consistent with shipped behavior.
- Around line 402-417: The blank-ID fallback in pathKey collides with the real
ID "plan", violating injective plan-path mapping. Replace the rawID fallback
with a reserved sentinel that cannot collide with valid session IDs, while
preserving stable results across calls; add a regression test verifying
PlanFilePath(root, "") and PlanFilePath(root, "plan") return different paths.

In `@internal/tools/update_plan_test.go`:
- Around line 12-28: Extend TestUpdatePlanRefusesCancelledRun to decode the
successful result’s PlanSnapshotMeta with encoding/json and assert it contains
the installed “live” plan, while asserting the cancelled result has no snapshot
metadata. Add a separate concurrent test that invokes Run and SetPlan(nil) from
different goroutines, waits for both to finish, and verifies CurrentPlan is
either empty or exactly the new session’s state; ensure the test is suitable for
execution with the race detector.

In `@internal/tui/plan_command.go`:
- Around line 237-249: Update reloadPlanFromFile to return the ReadPlan error
separately from the missing-plan false result, preserving the existing item
reload behavior. Adjust the /plan enter call site to discard the new error
value, and update the planEditorFinishedMsg handler to append a transcript error
containing the read failure before returning; retain the existing silent return
only when no plan exists.

---

Nitpick comments:
In `@internal/agent/loop_test.go`:
- Around line 3448-3463: Update TestPlanModeRejectsNameOnlySpoofedControlTools
to cover both “update_plan” and “ask_user” as claimed, preferably with
table-driven subtests, and thread each toolName through provider events and
advertisement assertions. Alternatively, narrow the test comment to describe
only the currently exercised “update_plan” case.
- Around line 4035-4074: Update the test around the dispatcher and marker setup
so the hook’s command operates on an already-created temporary directory and
creates a file inside it when executed. Change the final filesystem assertion to
check that file remains absent, ensuring the test detects actual hook execution
rather than only a failed go command.

In `@internal/agent/request_permissions_test.go`:
- Around line 149-151: Update the assertion for executeRequestPermissions’
plan-mode denial to also require the result’s DenialReason to equal
DenialFiltered, while preserving the existing status and output checks.

In `@internal/planmode/planmode.go`:
- Around line 204-218: Update StageForEditor to avoid filepath.EvalSymlinks and
path-based staging; open the staging parent with os.OpenRoot, then use the
resulting os.Root methods for Chmod and CreateTemp so validation and file
creation remain handle-relative. Adapt stageContentForEditor to accept and use
the root handle, while preserving the existing privacy checks and error
behavior.

In `@internal/tools/update_plan.go`:
- Around line 108-117: Update updatePlanTool.SetPlan to copy the incoming plan
slice before enforcing statuses and storing it, ensuring the tool owns its
backing array and caller mutations cannot affect currentPlan. Preserve the
existing enforceSingleInProgress behavior while making the stored plan
consistent with CurrentPlan’s copy-on-boundary behavior.

In `@internal/tui/plan_command_test.go`:
- Around line 148-170: Add a regression test alongside TestBarePlanTogglesOff
for unknown handlePlanCommand subcommands such as “openx”, “status”, and “on”
while PermissionModePlan is active. Assert no command is returned, plan mode
remains active, and the transcript contains the “Unknown /plan subcommand”
error, ensuring invalid input cannot fall through to the bare toggle.
- Around line 328-337: Canonicalize both path values before the containment
assertion in the plan path test: resolve symlinks for cwd and the value returned
by planmode.PlanFilePath, handle resolution errors through the test, then
perform the existing workspace-prefix and equality checks on the canonical
paths. Keep the .zero absence assertion unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8db6760e-11f3-4350-9967-841e55455887

📥 Commits

Reviewing files that changed from the base of the PR and between 8e26679 and f03e227.

📒 Files selected for processing (24)
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/agent/plan_mode_advertised_test.go
  • internal/agent/request_permissions_test.go
  • internal/agent/types.go
  • internal/planmode/export_test.go
  • internal/planmode/planmode.go
  • internal/planmode/planmode_test.go
  • internal/tools/types.go
  • internal/tools/update_plan.go
  • internal/tools/update_plan_test.go
  • internal/tui/btw.go
  • internal/tui/btw_test.go
  • internal/tui/commands.go
  • internal/tui/commands_test.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/plan_command.go
  • internal/tui/plan_command_test.go
  • internal/tui/session.go
  • internal/tui/session_test.go
  • internal/tui/spec_mode.go
  • internal/tui/spec_mode_test.go
  • internal/tui/view.go

Comment thread internal/planmode/planmode_test.go
Comment thread internal/planmode/planmode_test.go
Comment thread internal/planmode/planmode.go Outdated
Comment thread internal/planmode/planmode.go
Comment thread internal/planmode/planmode.go
Comment thread internal/tools/update_plan_test.go
Comment thread internal/tui/plan_command.go Outdated
euxaristia added a commit to euxaristia/zero that referenced this pull request Aug 7, 2026
…load

Fail closed when the workspace root cannot be resolved for editor staging,
use a non-colliding blank-session pathKey sentinel, copy on SetPlan so
enforceSingleInProgress cannot mutate the caller, surface plan-file read
errors from the editor reload path, and tighten regression coverage for
workspace containment, StageForEditor, and plan_snapshot metadata.

Refs Gitlawb#854
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed the latest CodeRabbit review on tip 7f66e661:

  1. WritePlan workspace test — isolate temp root and assert the workspace containment error text.
  2. StageForEditor — reject config-in-workspace; success path stages under a real config staging dir outside OS temp.
  3. WritePlan temp comment — no longer claims a "random" suffix (PID + nanoseconds + O_EXCL).
  4. editorStagingDirIsPrivate — fail closed when filepath.Abs(workspaceRoot) fails.
  5. pathKey — blank-session sentinel \x00no-session so ID "plan" does not collide; regression test added.
  6. update_planSetPlan copies the caller slice; tests cover PlanSnapshotMeta, cancelled runs omit it, concurrent cancel/reset under -race.
  7. reloadPlanFromFile — returns read errors separately; planEditorFinishedMsg surfaces them in the transcript.

Comment thread internal/planmode/planmode.go
@euxaristia

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
internal/planmode/planmode_test.go (1)

344-346: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tighten this assertion.

The condition accepts either substring. StageForEditor returns exactly one message for this case, so assert that message. A weaker error path would still pass today.

🧪 Proposed change
-	if !strings.Contains(err.Error(), "sandbox-writable") && !strings.Contains(err.Error(), "workspace") {
-		t.Fatalf("expected workspace/staging containment error, got: %v", err)
+	if !strings.Contains(err.Error(), "sandbox-writable") {
+		t.Fatalf("expected staging containment error, got: %v", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/planmode/planmode_test.go` around lines 344 - 346, In the
StageForEditor test assertion, replace the OR-based substring check with an
exact assertion against the expected error message returned for this case.
Preserve the existing failure output while ensuring weaker alternative error
messages cannot satisfy the test.
internal/agent/loop_test.go (1)

4077-4134: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the duplicated go binary lookup into a test helper.

Lines 4081-4091 repeat Lines 4020-4030 verbatim. A shared helper keeps the skip condition and the Windows suffix logic in one place.

♻️ Suggested helper
// testGoBinary resolves the go binary for hook tests that need a real
// executable, skipping when the toolchain is not reachable.
func testGoBinary(t *testing.T) string {
	t.Helper()
	if goBinary, err := exec.LookPath("go"); err == nil {
		return goBinary
	}
	goBinary := filepath.Join(runtime.GOROOT(), "bin", "go") //nolint:staticcheck // Safe for this non-portable test binary.
	if runtime.GOOS == "windows" {
		goBinary += ".exe"
	}
	if _, err := os.Stat(goBinary); err != nil {
		t.Skipf("go binary unavailable on PATH or in GOROOT: %v", err)
	}
	return goBinary
}

Then both tests reduce to:

-	goBinary, err := exec.LookPath("go")
-	if err != nil {
-		goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
-		goBinary = filepath.Join(goRoot, "bin", "go")
-		if runtime.GOOS == "windows" {
-			goBinary += ".exe"
-		}
-		if _, statErr := os.Stat(goBinary); statErr != nil {
-			t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
-		}
-	}
+	goBinary := testGoBinary(t)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop_test.go` around lines 4077 - 4134, Extract the duplicated
Go executable lookup from TestBeforeToolStillRunsInPlanMode and the nearby hook
test into a shared testGoBinary helper. Preserve PATH lookup, GOROOT fallback,
Windows suffix handling, missing-binary skip behavior, and mark the helper with
t.Helper(); update both tests to call it.
internal/agent/loop.go (1)

3227-3249: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add a Safety classification for host-process spawning.

The current built-in tools have only lsp_navigate with SideEffectRead + PermissionAllow that starts a process. A classification-based exclusion prevents this allowlist from becoming stale when another tool gains the same behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop.go` around lines 3227 - 3249, Add a dedicated Safety
side-effect classification for tools that spawn host processes, apply it to
lsp_navigate, and update toolAdvertisedInPlan to exclude that classification
instead of checking the tool name. Preserve the existing read-only and
permission checks for other tools.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/loop_test.go`:
- Around line 4011-4075: Add a regression test named
TestAfterToolSuppressedInPlanMode alongside the existing plan-mode hook tests.
Configure an EventAfterTool hook matching read_file, invoke dispatchAfterTool
with PermissionModePlan and a successful ToolCall, then assert no feedback is
returned and the audit contains no hook_execution_started event.

In `@internal/planmode/planmode.go`:
- Around line 73-84: Update ReadPlan to open the plan file through a handle with
syscall.O_NOFOLLOW on Linux, then read from that handle and close it, preserving
the existing not-found and wrapped-read-error behavior. Keep the Lstat-based
symlink check only as the Windows fallback, ensuring the file is not reopened by
name after validation.
- Around line 209-211: Update StageForEditor’s staging privacy check in
internal/planmode/planmode.go:209-211 to pass effectiveTempDir() instead of
os.TempDir(), matching ensurePlanPathContained’s test seam. In
internal/planmode/planmode_test.go:349-361, set a throwaway override with
SetTempDirForTest and construct configDir beneath t.TempDir() rather than beside
os.TempDir(), preserving cross-platform test behavior.

In `@internal/tui/btw.go`:
- Around line 210-212: Handle the error returned by reloadPlanFromFile in
internal/tui/btw.go lines 210-212 by reporting reload failures and synchronizing
both the restored panel and shared update_plan state; apply the equivalent fix
in internal/tui/session.go lines 256-258 for /resume, keeping destination plan
state consistent. Add regression tests covering unreadable and malformed plan
files in both flows.

In `@internal/tui/plan_command.go`:
- Around line 302-309: Remove the dead initial assignment to lineBody in the
surrounding parsing logic; declare it without initializing it, then retain the
existing branch assignments for the three whitespace cases so ineffassign passes
without changing behavior.
- Around line 50-61: Reorder the switch clauses in the /plan argument handling
so the default clause is last, after the case "off", "exit" and case "open"
blocks. Preserve the existing unknown-subcommand error message and return
behavior while satisfying ST1015 lint requirements.

---

Nitpick comments:
In `@internal/agent/loop_test.go`:
- Around line 4077-4134: Extract the duplicated Go executable lookup from
TestBeforeToolStillRunsInPlanMode and the nearby hook test into a shared
testGoBinary helper. Preserve PATH lookup, GOROOT fallback, Windows suffix
handling, missing-binary skip behavior, and mark the helper with t.Helper();
update both tests to call it.

In `@internal/agent/loop.go`:
- Around line 3227-3249: Add a dedicated Safety side-effect classification for
tools that spawn host processes, apply it to lsp_navigate, and update
toolAdvertisedInPlan to exclude that classification instead of checking the tool
name. Preserve the existing read-only and permission checks for other tools.

In `@internal/planmode/planmode_test.go`:
- Around line 344-346: In the StageForEditor test assertion, replace the
OR-based substring check with an exact assertion against the expected error
message returned for this case. Preserve the existing failure output while
ensuring weaker alternative error messages cannot satisfy the test.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8330ef1d-b5c4-40ed-a140-1e5978adcfbc

📥 Commits

Reviewing files that changed from the base of the PR and between 8e26679 and 7f66e66.

📒 Files selected for processing (24)
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/agent/plan_mode_advertised_test.go
  • internal/agent/request_permissions_test.go
  • internal/agent/types.go
  • internal/planmode/export_test.go
  • internal/planmode/planmode.go
  • internal/planmode/planmode_test.go
  • internal/tools/types.go
  • internal/tools/update_plan.go
  • internal/tools/update_plan_test.go
  • internal/tui/btw.go
  • internal/tui/btw_test.go
  • internal/tui/commands.go
  • internal/tui/commands_test.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/plan_command.go
  • internal/tui/plan_command_test.go
  • internal/tui/session.go
  • internal/tui/session_test.go
  • internal/tui/spec_mode.go
  • internal/tui/spec_mode_test.go
  • internal/tui/view.go

Comment thread internal/agent/loop_test.go
Comment thread internal/planmode/planmode.go Outdated
Comment thread internal/planmode/planmode.go Outdated
Comment thread internal/tui/btw.go Outdated
Comment thread internal/tui/plan_command.go
Comment thread internal/tui/plan_command.go Outdated
euxaristia added a commit to euxaristia/zero that referenced this pull request Aug 7, 2026
…mode

Bind plan reads at open with O_NOFOLLOW on Unix, route StageForEditor through
the temp-dir test seam so CI staging privacy checks pass, surface durable plan
reload failures from /btw return and /resume, fix plan_command switch/lint
nits that fail CI, and pin afterTool suppression in plan mode.

Refs Gitlawb#854
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed the open CodeRabbit findings on ac88fb9:

  1. afterTool plan-mode suppression — added TestAfterToolSuppressedInPlanMode in internal/agent/loop_test.go.
  2. ReadPlan bind-at-open — Unix O_NOFOLLOW open via read_unix.go; Lstat fallback on non-Unix via read_other.go.
  3. StageForEditor CI privacy seameffectiveTempDir() in the staging check; test uses isolatePlanStorage under t.TempDir().
  4. Plan reload errors — surface failures from leaveBTW and /resume; regression tests for unreadable plan paths.
  5. ST1015default clause moved to end of /plan switch.
  6. ineffassign — removed dead lineBody initializer in parsePlanFileLines.

Verified: gofmt, go vet, targeted package tests, and golangci-lint (unused,ineffassign,staticcheck) on the touched packages.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Rate Limit Exceeded

@euxaristia have exceeded the limit for the number of chat messages per hour. Please wait 19 minutes and 14 seconds before sending another message.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/planmode/read_unix.go`:
- Around line 16-25: The plan-file readers must bind containment to trusted
directory handles instead of relying on pathname traversal. In
internal/planmode/read_unix.go, update readPlanFile to open each directory
component with no-follow protection and open the final file relative to that
handle; in internal/planmode/read_other.go, use traversal-resistant
handle-relative APIs for non-Unix platforms or fail closed when unavailable,
rather than calling os.ReadFile by pathname. Add regression coverage for
intermediate-component symlinks and Windows reparse points.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 298611ca-0d23-4fbe-aaa3-b1a4800c258b

📥 Commits

Reviewing files that changed from the base of the PR and between 7f66e66 and ac88fb9.

📒 Files selected for processing (10)
  • internal/agent/loop_test.go
  • internal/planmode/planmode.go
  • internal/planmode/planmode_test.go
  • internal/planmode/read_other.go
  • internal/planmode/read_unix.go
  • internal/tui/btw.go
  • internal/tui/btw_test.go
  • internal/tui/plan_command.go
  • internal/tui/session.go
  • internal/tui/session_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • internal/tui/btw.go
  • internal/tui/session.go
  • internal/agent/loop_test.go
  • internal/planmode/planmode_test.go
  • internal/planmode/planmode.go
  • internal/tui/plan_command.go

Comment thread internal/planmode/read_unix.go Outdated
euxaristia added a commit to euxaristia/zero that referenced this pull request Aug 7, 2026
Final-component O_NOFOLLOW left intermediate directory swaps able to
redirect plan reads outside the storage tree. Open the plans base as
os.Root and read relative to that handle so traversal cannot escape,
and refuse a symlink final component. Add intermediate-symlink and
plain-file regression coverage.

Refs Gitlawb#854
@euxaristia

Copy link
Copy Markdown
Contributor Author

CodeRabbit follow-up (finding 3738089757)

SHA: 1c385ca77a9d555738a8ebd51ba51627ad57f2d2

What changed

  • Valid finding: final-component O_NOFOLLOW did not bind containment during directory traversal; a replaced intermediate parent could redirect ReadPlan outside the plans root.
  • Fix: readPlanFile now opens the plan storage base via os.OpenRoot and opens the plan path relative to that handle (traversal-resistant on Unix and Windows). Final-component symlinks are still refused.
  • Files: added internal/planmode/read.go; removed read_unix.go / read_other.go; ReadPlan passes the storage base; tests cover intermediate symlink/reparse, final symlink, and plain-file read.

Verification

  • go test ./internal/planmode/
  • go vet ./internal/planmode/

No @coderabbitai full review this round (rate-limit cooldown).

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Rate Limit Exceeded

@euxaristia have exceeded the limit for the number of chat messages per hour. Please wait 6 minutes and 24 seconds before sending another message.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/planmode/read.go`:
- Around line 33-40: Update the file-opening flow around root.Lstat and
root.Open to atomically refuse final-component symlinks: use a no-follow open
that also protects against Windows reparse points, then verify the opened handle
identifies a regular file before reading. Preserve the existing symlink refusal
error behavior where applicable, and add a regression test that replaces the
requested file with a symlink between path inspection and opening.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b2d1d138-3cc7-45ed-9e59-ba06772c08cd

📥 Commits

Reviewing files that changed from the base of the PR and between ac88fb9 and 1c385ca.

📒 Files selected for processing (3)
  • internal/planmode/planmode.go
  • internal/planmode/planmode_test.go
  • internal/planmode/read.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/planmode/planmode_test.go
  • internal/planmode/planmode.go

Comment thread internal/planmode/read.go Outdated
euxaristia added a commit to euxaristia/zero that referenced this pull request Aug 7, 2026
os.Root.Open follows in-root symlinks after O_NOFOLLOW fails, so a
root.Lstat then root.Open sequence could race and read a swapped target.
Walk with true no-follow opens (openat O_NOFOLLOW / OBJ_DONT_REPARSE),
verify a regular file, and cover the in-root replace-with-symlink case.

Refs Gitlawb#854
@euxaristia

Copy link
Copy Markdown
Contributor Author

CodeRabbit major (3738164693): TOCTOU on plan read fixed

SHA: f2fc3039d97c7e8eaffec82466c9a78eb027e286

internal/planmode/read.go no longer does root.Lstat then root.Open. That pair was racy, and os.Root.Open follows in-root final-component symlinks after O_NOFOLLOW fails (checkSymlink).

Fix

  • Unix (read_unix.go): handle-relative walk with openat(O_NOFOLLOW|O_DIRECTORY) for intermediates and openat(O_NOFOLLOW|O_RDONLY) for the final name; nofollow errors map to plan file %s is a symlink; refusing to read through it; fstat requires a regular file.
  • Windows (read_windows.go): same walk with NtCreateFile + OBJ_DONT_REPARSE (Go Root / O_NOFOLLOW_ANY primitive); refuse reparse points; require non-directory, non-reparse handle before read.
  • Dropped reliance on os.Root for the plan open path.

Tests

  • TestReadPlanFileRejectsInRootFinalSymlink
  • TestReadPlanFileRefusesAfterReplaceWithSymlink
  • Existing intermediate/final symlink and roundtrip tests still pass (go test ./internal/planmode/).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
internal/planmode/read.go (1)

36-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a sentinel error instead of substring matching.

ReadPlan in internal/planmode/planmode.go detects this refusal with strings.Contains(err.Error(), "is a symlink"). That couples the caller to the message text. A wrapped sentinel keeps the same user-facing text and makes the check explicit.

♻️ Proposed refactor
+// ErrPlanSymlink marks a refused symlink / reparse-point component.
+var ErrPlanSymlink = errors.New("is a symlink; refusing to read through it")
+
 func errPlanSymlink(path string) error {
-	return fmt.Errorf("plan file %s is a symlink; refusing to read through it", path)
+	return fmt.Errorf("plan file %s %w", path, ErrPlanSymlink)
 }

Then ReadPlan uses errors.Is(err, ErrPlanSymlink).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/planmode/read.go` around lines 36 - 40, Define an exported sentinel
error such as ErrPlanSymlink and have errPlanSymlink wrap it while preserving
the existing user-facing message. Update ReadPlan to detect this condition with
errors.Is(err, ErrPlanSymlink) instead of matching the error string, and remove
the substring-based check.
internal/planmode/planmode_test.go (1)

378-380: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Windows reparse-point coverage silently disappears here.

Both tests call t.Skipf when os.Symlink fails. On Windows without Developer Mode or SeCreateSymbolicLinkPrivilege, that is exactly what happens, so the entire read_windows.go walker ships with zero executed assertions. The skip is correct behavior for a symlink test; the gap is that nothing else covers the Windows path.

Add one Windows-only test that creates a directory junction with mklink /J (junctions need no special privilege) and asserts the walker refuses it. That exercises OBJ_DONT_REPARSE and isWindowsSymlinkErr on the platform they exist for.

As per coding guidelines: "path-sensitive logic must include a non-Linux case or a hermetic equivalent exercising the same normalization."

Also applies to: 425-427

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/planmode/planmode_test.go` around lines 378 - 380, Add a
Windows-only test alongside the symlink tests that creates a directory junction
via `mklink /J` without relying on `os.Symlink`, then invokes the walker and
asserts the junction is rejected. Exercise the Windows-specific
`read_windows.go` behavior, including `OBJ_DONT_REPARSE` and
`isWindowsSymlinkErr`, while leaving the existing privilege-dependent symlink
tests’ skip behavior unchanged.

Source: Coding guidelines

internal/planmode/read_unix.go (1)

81-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use errors.Is for the errno comparisons.

unix.Openat returns a syscall.Errno, so == works today. It breaks silently if the error is ever wrapped, and the failure mode is bad: a wrapped ELOOP would stop being reported as a symlink refusal and would surface as a raw errno instead. errors.Is keeps the same semantics and survives wrapping.

♻️ Proposed refactor
 func openatRetry(dirfd int, path string, flags int, mode uint32) (int, error) {
 	for {
 		fd, err := unix.Openat(dirfd, path, flags, mode)
-		if err == syscall.EINTR {
+		if errors.Is(err, syscall.EINTR) {
 			continue
 		}
 		return fd, err
 	}
 }
 
 func isNoFollowErr(err error) bool {
-	return err == syscall.ELOOP || err == syscall.EMLINK
+	return errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK)
 }

Add "errors" to the imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/planmode/read_unix.go` around lines 81 - 96, Update isNoFollowErr to
use errors.Is when comparing err against syscall.ELOOP and syscall.EMLINK, and
add the errors import. Preserve recognition of both platform-specific errno
values while allowing wrapped errors to match.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/planmode/read_windows.go`:
- Around line 185-204: Update mapWindowsOpenErr to map
windows.STATUS_NO_SUCH_FILE and the relevant intermediate-path-missing NTSTATUS
from the NtCreateFile walk to os.ErrNotExist, preserving the existing mappings.
Add Windows-specific coverage for ReadPlan when the session plan is missing
while the storage base exists, asserting it returns "", false, nil.

---

Nitpick comments:
In `@internal/planmode/planmode_test.go`:
- Around line 378-380: Add a Windows-only test alongside the symlink tests that
creates a directory junction via `mklink /J` without relying on `os.Symlink`,
then invokes the walker and asserts the junction is rejected. Exercise the
Windows-specific `read_windows.go` behavior, including `OBJ_DONT_REPARSE` and
`isWindowsSymlinkErr`, while leaving the existing privilege-dependent symlink
tests’ skip behavior unchanged.

In `@internal/planmode/read_unix.go`:
- Around line 81-96: Update isNoFollowErr to use errors.Is when comparing err
against syscall.ELOOP and syscall.EMLINK, and add the errors import. Preserve
recognition of both platform-specific errno values while allowing wrapped errors
to match.

In `@internal/planmode/read.go`:
- Around line 36-40: Define an exported sentinel error such as ErrPlanSymlink
and have errPlanSymlink wrap it while preserving the existing user-facing
message. Update ReadPlan to detect this condition with errors.Is(err,
ErrPlanSymlink) instead of matching the error string, and remove the
substring-based check.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 33871054-a167-4253-896b-05285317b085

📥 Commits

Reviewing files that changed from the base of the PR and between 1c385ca and f2fc303.

📒 Files selected for processing (5)
  • internal/planmode/planmode_test.go
  • internal/planmode/read.go
  • internal/planmode/read_other.go
  • internal/planmode/read_unix.go
  • internal/planmode/read_windows.go

Comment thread internal/planmode/read_windows.go
@euxaristia

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

euxaristia and others added 22 commits August 31, 2026 03:16
Add file.Sync() before the atomic rename on the Unix write path so the
temp file is fully flushed before replacing the plan. Drop the redundant
symlink conditional in WritePlan, which masked the underlying error. Cover
the full editor staging -> commit -> read roundtrip and the plan-reload
failure path with tests, and fix splitEditorCommandFor so unquoted Windows
editor values containing backslashes keep separators literal regardless of
whether they begin with a drive or UNC path.

Refs Gitlawb#854

Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com>
Keep the plan editor and durable file workflow while adopting main's explicit /plan on, /plan status, /plan off contract. Preserve terminal companion commands and the live Bubble Tea program field that /plan open needs after rebasing onto main.

Refs Gitlawb#854

Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com>
Keep plan-mode state accurate after file reload failures, report the mode actually restored by exitPlanMode, align help text with the explicit command contract, and cover staged editor write-back. Remove the unused model program reference.

Refs Gitlawb#854

Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com>
Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com>
…or parsing

Use an errors.Is sentinel for symlink refusals, chmod only the resolved staging directory after privacy validation, align Windows rename and delete information classes with their payloads, drop the duplicated reparse check and local prefix helper, detect unterminated Windows editor quotes, make test config roots unique, and assert the saved restore mode survives a same-session resume.

Refs Gitlawb#854

Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com>
…mode

Delete the dead toolAdvertisedInSpecDraft/toolAdvertisedInPlan duplicates in
loop.go that were failing the unused-func lint gate; the real advertisement
gate already delegates to tools.ToolAdvertisedForPermissionMode. Wire
planEnterText into /plan on instead of leaving it dead, and drop the
ineffectual parts reassignment in the editor-quote test that make
lint-static was failing on.

Fix ntObjectPath to stop treating \?\ (extended-length) and \.\ (device)
path prefixes as UNC, which produced a malformed NT path and failed every
plan read for a user whose %AppData% resolves through one. Make the
non-Unix/non-Windows fallback reader fail closed instead of opening a file
through a validate-then-open symlink race it cannot close. Sweep staged plan
files left behind when a Bubble Tea shutdown drops the tea.ExecProcess
command before its cleanup callback runs.

Fix two tests that didn't reach the behavior they claimed to guard:
TestWritePlanRefusesIntermediateSymlink only ever hit the outer containment
pre-check, never the handle-relative writer's own symlink refusal, and
TestStageForEditorRejectsStagingInsideWorkspace's setup broke plan storage
before StageForEditor could reach the staging-specific check (plan storage
and staging both resolve through the same UserConfigDir, so pointing config
at the workspace fails ReadPlan first — verified by running the review's own
suggested fix, which still failed). Add isolatePlanConfig to the
session-switch test that touches the real machine's plan directory, and
align /plan help text with the parser's status|on|open|off subcommands.

Refs Gitlawb#854
…mlink

openat(..., O_DIRECTORY|O_NOFOLLOW) reports ENOTDIR, not ELOOP, when the
named component is a symlink on Linux and Darwin: the kernel never
dereferences it to see the O_DIRECTORY mismatch it would otherwise report.
isNoFollowErr only recognized ELOOP/EMLINK, so the no-follow walkers in both
openPlanUnderBase (read) and writePlanFile's writer fell through to a
generic, unclassified error on those platforms instead of the intended
symlink refusal. The write path's refusal still failed closed (no write
occurred), just under the wrong error text, which is what surfaced this: the
prior commit's tightened TestWritePlanRefusesIntermediateSymlink assertion
failed on the ubuntu-latest and macos-latest smoke jobs.

Add isSymlinkDisguisedAsENOTDIR, shared by both walkers, which disambiguates
ENOTDIR with a no-follow stat so a genuine non-symlink, non-directory
component (a plain file blocking the path) still reports its real error
instead of a false symlink claim.

Refs Gitlawb#854
…an file

On a fresh TUI, or after /new, the session ID stays empty until the first
prompt lazily creates it, and PlanFilePath maps an empty ID onto a single
shared no-session slug. Plan-mode entry therefore has to create the session
before it reports anything about the plan file, or the banner points every
fresh session at the same shared path.

TestPlanOpenCreatesSessionBeforeWritingPlanFile only reaches this through the
/plan open that follows entry, so entry on its own was untested, including
the banner now naming the session's plan file. Assert both that /plan on
creates the session and that the banner carries that session's own path and
not the no-session fallback.

Recovered from an abandoned worktree, then adapted: the original drove entry
with a bare /plan, which now reports status instead of entering plan mode.

Refs Gitlawb#854
The planEditorFinishedMsg handler appended a session event on every
successful editor exit. Opening the plan with /plan open, reading it, and
quitting without saving therefore wrote "I edited the plan file directly.
Updated plan: ..." into the session. That event is phrased as the user's own
words, so the next turn saw a statement the user never made, and each
repeated open restated the whole plan body into the session log again.

Capture the plan before reloadPlanFromFile replaces it, compare it with the
reloaded items, and return early when they match, skipping both the
transcript note and the session event. planItemsEqual compares content,
status, and notes but not ID: parsePlanFileLines rebuilds items from the file
text without preserving in-memory IDs, so comparing IDs would report every
reload as a change.

TestPlanEditorFinishedMsgNoOpEditRecordsNothing fails without the guard with
"an unchanged plan file must not record a session event: before=0 after=1".

Refs Gitlawb#854
…base

Every component under the storage base was opened no-follow, but the base
itself was opened by path and followed links. ensurePlanPathContained
resolves the base and the plan path through the same link, so a link at
${UserConfigDir}/zero/plans passed containment unless its target happened to
be the workspace or the temp directory. The handle-relative walk was then
simply rooted inside the target, so every read, create, and rename landed
there while each individual component check still passed.

Open the base with O_NOFOLLOW on Unix and OBJ_DONT_REPARSE on Windows, and
report it through errPlanBaseSymlink, which wraps the existing
errPlanSymlinkRefusal sentinel so ReadPlan surfaces it like any other symlink
refusal. O_NOFOLLOW applies to the final component only, so a legitimately
symlinked ~/.config above the storage root still works. The Windows change is
one attribute on the shared openWindowsBaseDir, which both walkers already
use, and it matches the flags every component-level open there already sets.

TestPlanStorageBaseSymlinkRefused replaces the storage root with a link and
requires both ReadPlan and WritePlan to refuse and the target to stay empty.
Without the fix it fails on Linux with "expected ReadPlan to refuse a
symlinked plan storage root", verified in a container.

Refs Gitlawb#854
…st isolation

Address review comments:
- Document CommitStagedEdit trust contract for stagedPath.
- Remove redundant chmod from stageContentForEditor.
- Use errors.Is for errno checks in read_unix.go.
- Use unsafe.Slice in write_windows.go for UTF-16 rename path.
- Isolate plan config in TestNewSessionClearsPreviousPlan.

Refs Gitlawb#854
… isolation

Address review feedback:
- Tighten staging dir permissions in stageContentForEditor.
- Remove redundant pathname os.Chmod on base from writePlanFile.
- Preserve in-memory plan state on /plan on reload failure with regression test.
- Extend spoofed control-tool test to cover ask_user in loop_test.go.
- Isolate plan config in session and spec mode switch tests.
- Verify pending and activeRunID directly in spec mode create failure test.

Refs Gitlawb#854
Unsafe sessions were still advertised as bypass after /plan on because Shift+Tab was the only path that called syncPeerIdentity. Enter and exit now republish the current permission class.

Refs Gitlawb#854

Co-Authored-By: cairn-code <cairn-code@users.noreply.github.com>
Directory-symlink creation is privileged on many Windows runners, so TestPlanStorageBaseSymlinkRefused skips there. A junction is an unprivileged reparse point and exercises openWindowsBaseDir's OBJ_DONT_REPARSE mapping through WritePlan.

Refs Gitlawb#854
Automatic /loop ticks and /goal continuations cannot make progress in
plan mode, so entering /plan holds them and /plan off resumes them
instead of spending turns that cannot implement the plan.
Grants FILE_TRAVERSE on Windows directory handles used as RootDirectory
for NtCreateFile, since relative opens fail with STATUS_ACCESS_DENIED
without SeChangeNotifyPrivilege. Fails the non-Unix/non-Windows write
fallback closed to match the read side, since the prior os.Root-based
path had a check-to-use race and wrote plans that could never be read
back. Wraps errPlanSymlinkWrite around the shared errPlanSymlinkRefusal
sentinel so callers can detect write-side refusals with errors.Is like
the read side. Fixes stale test comments referencing a function that
was never shipped, pins the chmod ordering in the staging-privacy test,
and asserts the error from reloadPlanFromFile instead of discarding it.
Co-Authored-By: cairn-code <cairn-code@users.noreply.github.com>
Co-Authored-By: cairn-code <cairn-code@users.noreply.github.com>
…ment

editorStagingDirIsPrivate compares physical paths so a staging directory that
resolves into the workspace or the OS temp dir is refused, but physicalPath
resolved through filepath.EvalSymlinks, which hands a junction straight back:
os.Lstat maps one to ModeIrregular rather than ModeSymlink. A junction needs
no SeCreateSymbolicLinkPrivilege, so it is the reparse point an unprivileged
process can actually plant, and the check the function documents did not hold
on the one platform where that matters.

Resolve through GetFinalPathNameByHandle on Windows instead, which asks the
filesystem what the handle resolved to and so accounts for every reparse type
at once; VOLUME_NAME_DOS also returns long names, subsuming the 8.3 short-name
normalization the comparison already needed. verifyPrivateDirectory now
rejects a reparse point explicitly rather than relying on its !IsDir test
firing by accident, which is why a junctioned staging directory was refused
with "is not a directory".

The Windows staging tests skip wherever directory-symlink creation is
privileged, which is why this went unnoticed; the new ones use the junction
helper the storage tests already rely on. Verified on NTFS: both containment
tests fail before this change and pass after it.

Refs Gitlawb#854
Prevent queued messages from auto-launching on turn completion while plan mode is active, requiring explicit exit or submission before running.

Refs Gitlawb#854

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every blocking item from both reviews is closed, and the junction fix is exactly right. Still requesting changes, for one thing: four separate exits now drop plan mode's read-only gate silently.

Closed

The Windows staging containment (mine). resolvePhysical replaces filepath.EvalSymlinks at planmode.go:170 and :330, and the new physical_windows.go uses CreateFile(FILE_FLAG_BACKUP_SEMANTICS) plus GetFinalPathNameByHandle, with the extended-length prefix trimmed back to Win32 form. Driven with a real cmd /c mklink /J: both halves now return false and physicalPath(junction) == physicalPath(target), where before the fix they returned true. Reverting to EvalSymlinks fails three committed tests by name. This is the right API and it is the one I could not give another contributor correctly, so thank you for getting it right.

The backstop and the coverage (mine). pathIsReparsePoint is in verifyPrivateDirectory and the doc comment now matches. The six new junction tests actually RUN on an unelevated box rather than skipping, which was the whole problem last time. I also want to credit the comment in TestPlanFlowRefusesJunctionedConfigRootIntoWorkspace saying "this one passes before the fix as well, and that is worth recording rather than hiding". That is the right instinct and I confirmed it is true.

Queued prompts (@jatmn's P1). model.go:5313 now carries || m.planModeBlocksContinuations(), the same predicate goal.go:269 and loop.go:341 use. Driven end to end: held across repeated completions, released exactly once on /plan off, not replayed by the next completion, and Up still returns the text to the composer intact. Deleting the guard fails the test at plan_command_test.go:1077, so it is genuinely pinned. The rebase is done too, behind_by 0.

Plan mode is dropped by four exits, and base keeps it

/spec is the clearest. Base:

case commandRewind, commandExport, commandSandboxSetup, commandSpec, commandInit:

Head, plan_command.go:150, with commandSpec removed:

case commandRewind, commandExport, commandSandboxSetup, commandInit:

So /plan on then /spec leaves permissionMode="auto", and spec_mode.go:209-219 launches the approved implementation run with no mode override. Base blocked that command outright.

The other three behave the same way. btw.go:151 calls side.exitPlanMode(), so a side conversation gets "unsafe" or "ask" where base keeps "plan"; under plan the bash tool is not advertised and under unsafe it is. session.go:77 and :249 take /new and /resume <other> from plan to "unsafe". Nothing in the transcript says the mode changed in any of the four, and /btw is not blocked inside plan mode while /plan is blocked inside BTW, so it is one way.

None of the four is pinned by a test, all four diverge from base toward less restriction, and view.go:328-333 in this same PR argues the opposite case in a comment. That is what makes this the blocker rather than a note.

Three more, same round

The plan reload hands raw items to the caller and enforced items to the tool. plan_command.go:360-366 returns parsePlanFileLines(content) to the caller and passes the same slice to SetPlan, which applies enforceSingleInProgress. Driven through a real /plan on with a file carrying two [doing] steps: update_plan, which this code calls the execution source of truth, records step 1 as completed while the sticky panel and the file both still say in_progress. The user is never told.

A no-op editor session claims the user edited the plan. The guard at model.go:1481-1512 compares with planItemsEqual, but formatPlanItems to parsePlanFileLines is not the identity for ordinary model-authored text: leading and trailing whitespace, tabs and CR are all stripped. Five of seven realistic agent-written shapes fail the round trip, so opening the editor and quitting without a keystroke writes "I edited the plan file directly. Updated plan: ..." into the session as the user. That is verbatim what the comment above the guard forbids. The same round trip also mutates the agent's plan on a plain /plan on with no editor involved.

On Windows a benign reparse point above the plans root kills /plan entirely. read_windows.go:116-158 opens the whole absolute path with OBJ_DONT_REPARSE, so the refusal fires on any ancestor rather than only the storage root. That contradicts the contract read_unix.go:25-27 states, that "a legitimately symlinked ~/.config above the storage root is still fine". Driven with a junction two and four levels above the root, and with a subst drive where pathIsReparsePoint is false on every component while the error still says "is a symlink; refusing to open through it". On an affected machine /plan on and /plan status error, and model.go:5891-5893 then emits an error row on every successful update_plan call in every mode. It fails closed, so this is availability plus a false diagnostic rather than a containment hole, but base worked. Resolving the parent physically and no-following only the final component is the shape that fixes it.

Notes, not blocking

The reparse guard at planmode.go:304 is not actually pinned: stub pathIsReparsePoint to false and TestVerifyPrivateDirectoryRejectsJunction still passes, because os.Lstat maps a junction to ModeIrregular so !info.IsDir() fires first. The outcome is refusal either way, so the direction is safe. Just do not let that test name be read later as coverage of the guard.

Seven of the no-follow-walk tests still skip on Windows. I drove the gap by hand with an intermediate junction and a junction at the final .md name; both were refused and nothing escaped, but the refusal came from ensurePlanPathContained, not from the OBJ_DONT_REPARSE walk, so the walk's own component refusal remains unexercised here.

Peer-message turns at peer_messages.go:237 and :249 are not held by plan mode, unlike /loop, /goal and now the queued prompt. Such a turn runs with the plan toolset, so head is stricter than base and this is a note only.

Checked and correct

Build, vet, gofmt, GOOS=linux and GOOS=darwin all clean; internal/planmode passes; the only internal/tui failure is TestHandleAddDirCommand, identical on base. The loop paused flag has only plan-mode writers, so resumeLoopsAfterPlan clearing it unconditionally cannot clobber another pause source, and every exitPlanMode path without a resume also calls clearLoopsForSessionSwitch. /new discarding a queued message is base behaviour, untouched by your diff, so not yours. The scroll_test.go Cwd hunk is test hygiene, not a papered-over failure. Build tags on the new test files are right and no untagged test asserts a platform-only contract.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

These are not ten unrelated edge cases. Most come from four shared design gaps, and fixing only the individual symptom at each cited line is likely to produce another review round.

Overall guidance

  1. Make plan mode a single lifecycle/state-machine contract. Permission mode, the mode to restore, session ownership, queued/automatic continuations, the sticky panel, and user-visible status are currently changed by separate command handlers. Define one transition boundary for enter, explicit exit, session switch, and isolated side/spec surfaces. Each transition should atomically decide whether the read-only gate is preserved or exited, update every dependent field, and emit truthful user-visible state. /spec, /new, /resume, and /btw should not each hand-assemble part of that transition.

  2. Choose one canonical typed plan snapshot and derive every representation from it. The update tool, result metadata, sticky panel, editable text file, and durable file currently normalize or redact at different stages. Normalize once when accepting or parsing a plan, then pass one immutable typed snapshot to internal consumers. Render text from that snapshot, parse edits back into the same canonical form, and apply secret scrubbing only to transcript/display data—not to the internal persistence payload. The tool, panel, durable file, and next-turn context must never represent different plans.

  3. Bind filesystem security and cleanup to the object/owner, not a checked pathname or elapsed time. Once a staging directory passes containment and privacy checks, keep a handle to that directory and perform creation relative to it with no-follow/reparse protections. Likewise, reclaim an edit only when Zero can prove that exact staged file belongs to an abandoned invocation through an ownership marker plus a lease/lock or equivalent liveness mechanism. A resolved path and an mtime are not durable security or ownership evidence.

  4. Test the lifecycle matrix hermetically instead of adding one regression per review comment. Inject all config/cache/staging roots. Then cover mode transition × plan source × persistence result × platform: enter/exit, /new, /resume, /btw, /spec, editor no-op/edit, write failure, missing file, restart, multiple in_progress rows, secret-shaped content, concurrent staging, and Unix/Windows path redirection. For every row, assert permission mode, prior-mode state, canonical tool plan, panel plan, durable bytes, transcript claim, and cleanup outcome. This should catch a whole class of state drift rather than pinning only the latest symptom.

Findings

  • [P1] Keep the read-only gate until the user explicitly exits plan mode
    internal/tui/spec_mode.go:49

    Failure path: Start in a non-Plan permission mode, run /plan on, and then invoke /spec, /new, /resume <other>, or /btw. Each path calls exitPlanMode without /plan off or a warning, restoring the previous permission mode. /spec is the clearest impact: main blocks it while plan mode is active, but this head removes that guard, restores the prior non-Plan mode, and the approval path starts an implementation run with no plan override.

    Root cause: Plan mode is represented partly as a global permission value and partly as session-owned state, but session transitions independently call low-level reset/exit helpers. There is no single contract deciding whether a transition preserves the read-only gate, blocks the command, or requires explicit user confirmation.

    Required outcome: Route every plan/session transition through one state transition boundary. Relaxing the read-only gate must be explicit and visible; switching conversation state must not silently widen tool authority. Preserve the current behavior of explicit /plan off, including restoration of the prior permission choice.

    Regression coverage: Exercise all four commands from Plan entered from Auto, Ask, and Unsafe. Assert the permission/toolset before and after the transition, the transcript notice, and the eventual spec implementation run's permission mode.

  • [P1] Return the same normalized plan that execution stores
    internal/tui/plan_command.go:360

    Failure path: Put two [in_progress]/[doing] rows in the plan file and reload it. reloadPlanFromFile returns the raw parsed slice to the caller and sticky panel. SetPlan copies that slice and applies enforceSingleInProgress, demoting one item only in update_plan.CurrentPlan(). The displayed/file plan and the plan described as the execution source of truth now disagree, without telling the user.

    Root cause: Normalization is hidden inside one consumer (SetPlan) instead of being part of the parse/accept boundary. Multiple downstream consumers receive different representations of the same accepted input.

    Required outcome: Normalize once before publishing the snapshot, and use that exact canonical value for the tool, panel, durable representation, and session event. A useful API shape is for the accepting layer to return the canonical snapshot rather than letting a setter privately transform a copy, but the important contract is representation identity, not a specific API.

    Regression coverage: Reload a file with multiple active rows and assert field-for-field agreement among CurrentPlan, the sticky panel, formatted durable content, and the next-turn session context.

  • [P2] Compare editor contents in their serialized form before recording an edit
    internal/tui/model.go:1487

    Failure path: An agent-authored plan item can contain leading/trailing whitespace, tabs, carriage returns, or continuation shapes preserved by staging. parsePlanFileLines trims or normalizes those bytes. Opening the editor and quitting without a keystroke can therefore make planItemsEqual(beforeEdit, items) false, mutate the plan, and append “I edited the plan file directly” as a user message.

    Root cause: The no-op decision compares an in-memory pre-serialization structure with a post-serialization/post-parse structure. The comparison crosses a lossy boundary, so inequality does not prove the editor changed anything.

    Required outcome: Determine whether the editor changed the staged bytes before applying lossy parsing, or canonicalize both the before and after values through exactly the same round trip before comparison. Only create the user-authored session event when the editor actually changed the canonical plan. Keep plan reload behavior for genuine edits and clears.

    Regression coverage: Table-test whitespace, tabs, CRLF, multiline content, notes, escaped Notes: lines, and an empty plan. For every unchanged staged file, assert no transcript/session event and no plan mutation; for a one-byte real edit, assert exactly one update event.

  • [P1] Allow redirected ancestors above the protected Windows plan root
    internal/planmode/read_windows.go:131

    Failure path: openWindowsBaseDir passes the full absolute storage-base path to NtCreateFile with OBJ_DONT_REPARSE. Windows rejects reparse processing anywhere in that path, so a benign redirected profile/config ancestor, junction, or subst drive makes /plan on, /plan status, and every successful update_plan persistence attempt fail. The diagnostic also incorrectly describes the protected storage root as a symlink.

    Root cause: The code conflates an ancestor needed to reach the user's legitimate config location with the protected plans root or a descendant being redirected after containment was established. Applying the no-reparse rule to the complete absolute path is stricter than the documented boundary and unlike the Unix contract that permits a legitimately redirected ~/.config ancestor.

    Required outcome: Resolve/open the legitimate parent location first, bind it to a directory handle, and then open the final storage-root component and all descendants relative to that handle with Windows no-reparse protections. Continue refusing redirection at the protected root and below; do not weaken containment to regain availability.

    Regression coverage: On Windows, cover junctions two and four levels above the plans root and a subst path as accepted ancestors, plus junction/reparse replacement of the final root and intermediate plan components as refused cases for both read and write.

  • [P2] Keep plan tests out of the real user home
    internal/tui/plan_command_test.go:41

    Failure path: isolatePlanConfig calls os.UserHomeDir() and creates ~/.cache/zero-planmode-test. The new tests fail in a valid read-only-home environment and leave a shared parent directory on developer/CI machines. On macOS and Windows, setting only XDG/AppData variables also does not redirect every root that standard-library config/cache helpers may resolve.

    Root cause: Production staging rejects the workspace and OS temp directory, so the tests work around that restriction by borrowing the developer's home instead of injecting a test-owned storage/staging base. The test seam does not cover every platform-specific root.

    Required outcome: Add a narrow storage/staging-root injection seam and point it at a test-owned directory that is neither the workspace nor the default sandbox temp root. Redirect HOME, config, cache, AppData, and LocalAppData where platform APIs require them, or preferably pass the root directly so tests do not depend on ambient user-directory resolution.

    Regression coverage: Run the affected packages with a read-only HOME and with real config/cache paths sentinel-protected. Assert the tests create nothing outside t.TempDir() or the injected root on Linux, macOS, and Windows.

  • [P1] Do not scrub the internal plan snapshot used for persistence
    internal/tools/update_plan.go:97

    Failure path: A successful update_plan call stores the canonical plan in updatePlanTool.currentPlan and serializes another copy into PlanSnapshotMeta. Registry result processing then runs scrubResultSecrets over every metadata value before OnToolResult decodes that snapshot for the panel and durable file. A plan step containing an actual or false-positive secret-shaped string becomes [REDACTED] on disk and in the panel while tool memory retains the original. Resume later loads the altered file and makes that different plan authoritative.

    Root cause: Internal control data and user-visible/transcript metadata share the same mutable Meta transport. A presentation security boundary correctly scrubs transcript data but unintentionally mutates the state-transfer payload.

    Required outcome: Carry the accepted plan through a typed internal snapshot field or callback payload that is immutable and excluded from transcript/session serialization. Continue scrubbing Output, display fields, and ordinary metadata before they reach logs or UI. Persist exactly the canonical plan accepted by the tool; do not recover it by rereading shared mutable tool state.

    Regression coverage: Use secret-shaped fixtures and false-positive identifiers. Assert visible output/meta is redacted, while the internal snapshot, tool memory, panel plan, durable file, and resume result remain identical to the accepted canonical input.

  • [P2] Do not delete staged edits based only on age
    internal/planmode/planmode.go:224

    Failure path: Every non-directory entry in the shared plan-edit directory whose mtime is older than six hours is removed. A second Zero instance can sweep the staged file of a first editor session that is legitimately still open; on Unix the editor may retain an unlinked descriptor and the eventual commit fails because the pathname is gone. The sweep has no filename or ownership filter, so any old non-directory entry is eligible.

    Root cause: Age is being used as proof of both ownership and abandonment. It proves neither: interactive edits can exceed the threshold, mtimes need not reflect editor liveness, and a shared directory can contain files from another process or version.

    Required outcome: Give each staged edit explicit ownership and liveness state—for example a run/edit ID plus a held lock or lease—and reclaim only files proven to be Zero-owned and abandoned. If reliable proof is unavailable, leave the file for explicit cleanup rather than risking active user work. A filename prefix alone is an ownership filter, not sufficient liveness evidence.

    Regression coverage: Hold one staged edit open past the threshold, start a second staging operation, and verify the first survives. Separately verify that a process-death/expired-lease case is reclaimed and that unrelated old files are never touched.

  • [P1] Preserve an in-memory parent plan across BTW
    internal/tui/btw.go:213

    Failure path: The BTW side model is a value copy, but it shares the parent's registry pointer. resetPlanForSessionSwitch on entry clears the shared update_plan tool, thereby clearing the parent's only in-memory plan too. Return restores from disk only; when a prior plan-file write failed or the durable file is missing, the error or ok == false branches clear the parent's sticky panel, permanently losing the usable plan.

    Root cause: Session isolation is implemented as a shallow model copy around mutable shared tool state, then disk is treated as the only restoration mechanism even on paths where persistence is known to be fallible. The parent snapshot is never captured independently before the side reset.

    Required outcome: Make plan state session-owned rather than registry-global, or capture an immutable parent snapshot before entering BTW and restore that exact snapshot when durable reload cannot provide a newer valid value. A failed reload should surface the error without destroying the last known-good parent plan. Preserve the isolation rule that the side conversation must not inherit or overwrite the parent's plan.

    Regression coverage: Cover BTW entry/return with successful persistence, a forced write failure, a missing file, a read error, and a side plan update. Assert the parent canonical plan and panel survive failure, a valid newer durable plan wins when appropriate, and side state never leaks into the parent.

  • [P2] Make /plan status reflect the actual permission mode
    internal/tui/plan_command.go:487

    Failure path: planText never checks m.permissionMode. With a durable plan it prints “Current Plan (plan mode),” and without one it prints “Plan mode is active,” even after /plan off or a session transition restored a non-Plan mode. The command therefore gives a false read-only safety signal.

    Root cause: The presence or content of a saved plan is conflated with the active permission mode. Those are independent facts: a session may retain a plan after leaving plan mode, and plan mode may be active before any plan is written.

    Required outcome: Build status from two explicit values: whether PermissionModePlan is active and whether a saved/draft plan exists. Render both truthfully, such as “mode inactive; saved plan available” versus “mode active; no plan written.” Reuse the same status source for command output, view badges, and peer identity so UI surfaces cannot drift.

    Regression coverage: Test on/off with no plan, an in-memory draft, and a durable plan; then repeat after /new, /resume, /btw, and /spec transitions. Assert both the permission state and exact status classification.

  • [P1] Bind editor staging to the directory that passed validation
    internal/planmode/planmode.go:234

    Failure path: StageForEditor resolves dir, verifies the resolved directory is outside sandbox-writable roots, tightens permissions, and checks it is not a symlink or reparse point. It then hands only the pathname to stageContentForEditor, which performs fresh MkdirAll, Chmod, and CreateTemp path traversals. A concurrent rename/replacement after verification can redirect creation into a different directory, reopening the check-to-use race the staging hardening is meant to close.

    Root cause: Containment is proven for a pathname at one instant instead of being bound to the filesystem object used for creation. Repeating pathname operations after validation discards the identity that was checked. Random filenames and O_EXCL prevent pre-planting the final leaf but do not protect traversal of replaced parent components.

    Required outcome: Open the validated staging directory once, verify that handle/object, and create the random leaf relative to it using handle-relative no-follow or reparse-safe APIs on Unix and Windows. Avoid re-running MkdirAll and Chmod by pathname inside the creation helper. Keep the random exclusive leaf and restrictive permissions as additional layers, not substitutes for traversal binding.

    Regression coverage: Add a deterministic seam that replaces or renames the directory after validation but before create, and assert creation either remains under the originally checked handle or fails closed. Exercise symlink and junction replacements on every supported platform and verify no file appears in the attacker-selected target.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Most of these are symptoms of four shared design gaps. Fixing only the cited branch is likely to leave the same inconsistency reachable through another lifecycle path.

Why this PR keeps producing follow-up findings

The repeated review rounds are not happening because the feature needs an endless collection of isolated edge-case patches. They are happening because the implementation does not yet have one authoritative contract for permission lifecycle, plan state, persistence concurrency, or test isolation. Each fix has generally hardened the specific path named in the previous review, while a sibling producer or consumer continues to implement a slightly different rule.

The plan currently exists in several independently transformed forms:

  • mutable state inside update_plan;
  • a typed snapshot attached to a tool result;
  • the sticky TUI panel;
  • editable serialized text;
  • the durable per-session file;
  • reconstructed session/next-turn context;
  • entry-time fallback snapshots used by session and BTW transitions.

Those forms do not share a single acceptance and publication boundary. Some normalize statuses, some trim text, some infer presence from length, some are updated asynchronously, and some are restored only after storage succeeds. That is why fixing secret scrubbing with typed transport exposed the empty-snapshot presence bug, fixing editor no-op reporting still left a lossy comparison, and fixing BTW restoration with an entry snapshot still left the hidden-parent update race.

The same pattern exists in permission handling. Plan mode is represented by permissionMode, permissionModeBeforePlan, session identity, paused continuations, the advertised toolset, and visible status, but /plan, /spec, /new, /resume, and /btw each assemble their own subset of the transition. A local fix can make one command correct while another still widens authority or restores stale state.

Filesystem hardening has likewise been applied at individual pathname operations. Security and correctness here depend on longer-lived identity: which directory object was validated, whether a file was created by Zero, whether its owner is still alive, and which durable version an editor staged. A suffix, resolved path, mtime, or final atomic rename answers only one part of that contract.

Finally, the tests have mostly accumulated one regression per reported symptom. They do not yet assert the complete state vector across every transition, so a patch can satisfy the new test while moving the inconsistency to the panel, durable bytes, next-turn context, another platform, or another session path. Broad edits to shared test files have also removed unrelated live-target protections, reducing the suite's ability to catch that movement.

The findings map to those causes as follows:

Root contract Findings it explains Shared invariant that is missing
Permission/session lifecycle read-only exit; BTW fallback One transition decides authority, ownership, continuation state, visible status, and restoration together.
Canonical plan publication noncanonical reload; false editor edit; empty clear; BTW fallback One accepted typed snapshot—with explicit presence and provenance—is published unchanged to every internal consumer.
Persistence identity and concurrency Windows ancestors; stale-file ownership; editor overwrite Operations remain bound to the validated directory/object, owned staging invocation, and baseline durable version.
Hermetic regression protection agent-loop test rollback; real-home writes; missing lifecycle combinations Tests inject every external root and assert all representations/effects for the full transition matrix.

Overall guidance

  1. Make plan mode a single lifecycle contract. Entry, explicit exit, session changes, spec mode, BTW, queued work, the prior permission mode, and user-visible status should go through one transition boundary. That boundary should decide whether the read-only gate remains active, requires an explicit exit, or blocks the transition, and should update all dependent state atomically.
  2. Accept and publish one canonical typed plan snapshot. Normalize once at the tool/file input boundary, then use the same immutable value for tool memory, the sticky panel, durable storage, and next-turn context. Text formatting and parsing should be representation boundaries, not additional hidden normalization stages.
  3. Bind persistence to identity, ownership, and version. A staged editor file needs positive Zero ownership, liveness, and the durable version it was based on. Cleanup must prove ownership and abandonment; commit must detect a newer durable value instead of blindly replacing it.
  4. Test the lifecycle matrix hermetically. Inject session/config/cache/staging roots, then cover enter/exit, session/spec/BTW transitions, empty and noncanonical plans, editor no-op/edit/conflict, persistence failures, restart, and Unix/Windows redirection. For each case, assert permission mode, canonical tool state, panel state, durable bytes, session context, and cleanup effects.

Suggested repair sequence

  1. Write down the state invariants before changing more branches. At minimum: Plan authority never widens implicitly; one session owns one canonical plan; every successful update, including an empty clear, carries an immutable present snapshot; tool, panel, disk, and context agree after every completed transition; a failed restore never replaces newer known-good state; cleanup deletes only proven-owned abandoned files; and an editor cannot silently overwrite a newer durable version.
  2. Introduce the canonical acceptance boundary. Parse and normalize tool/file input once, retain explicit snapshot presence and session/run provenance, and return the accepted immutable snapshot from the accepting operation. Make panel, persistence, context, and fallback state consume that value rather than re-reading shared tool state or privately normalizing another copy.
  3. Centralize lifecycle transitions. Give plan entry, explicit exit, session switch, spec, and BTW one transition API that receives the intended event and atomically produces the new permission mode, prior-mode state, session ownership, continuation state, plan snapshot, and user-visible notice. Command handlers should request a transition, not manually combine exitPlanMode, reset, reload, and resume helpers.
  4. Add versioned editor semantics and owned cleanup. Stage the canonical bytes together with their baseline durable identity/version and a positively identifiable Zero invocation. On return, distinguish unchanged, edited-without-conflict, and edited-after-concurrent-update cases before writing. Reclaim only an owned invocation whose liveness is conclusively gone.
  5. Bind platform storage to handles at the intended trust boundary. Permit legitimate config ancestors, then open the protected plan root relative to the accepted parent and no-follow every protected component. Use the same object-bound principle for staging creation and reclamation on Unix and Windows.
  6. Build table-driven lifecycle tests before applying the final fixes. Drive each event from each relevant starting mode and plan source, inject read/write/missing/conflict outcomes, and assert the entire state vector. Run the same storage contract with redirected ancestors and protected-root replacement on Windows, plus symlink paths on Unix. Keep all session/config/cache roots test-owned.
  7. Restore unrelated mainline assertions and then make production changes. This prevents the repair from trading plan-mode correctness for regressions in spec safety, tool-result history, or trace integrity.

Completion invariants

This should be ready for another review when the implementation and tests demonstrate all of the following, rather than only the ten examples below:

  • Leaving or switching a conversation cannot widen Plan authority without an explicit visible user action.
  • A plan accepted from either update_plan or the editor has exactly one canonical typed representation.
  • Successful nonempty updates and successful empty clears are both present snapshots and propagate identically.
  • Tool memory, sticky panel, durable bytes, resume state, and next-turn context agree after update, edit, clear, restart, session switch, BTW, and persistence failure.
  • Opening and closing an unchanged editor is observationally a no-op.
  • A concurrent newer plan is never silently replaced by an older unchanged editor snapshot or stale transition fallback.
  • Benign user-profile/config redirection remains supported, while replacement at the protected storage root or below fails closed.
  • Cleanup touches only files proven to belong to an abandoned Zero staging invocation.
  • Tests write only to injected review-owned roots and preserve unrelated mainline security/protocol assertions.

Findings

  • [P1] Keep the read-only gate until an explicit visible exit
    internal/tui/plan_command.go:147

    Failure path: Enter plan mode from Auto, Ask, or Unsafe, then invoke /spec, /new, cross-session /resume, or /btw. The current head removes /spec from planModeCommandUnavailable, and these paths call exitPlanMode without /plan off or confirmation. The visible surface can therefore regain implementation tools even though the user never explicitly relaxed the read-only gate.

    Root cause: Plan mode is split between a global permission value, a saved prior mode, and independently implemented session/side-surface transitions. Those transitions call low-level reset and exit helpers without a single policy deciding whether authority may widen.

    Required outcome: Route every plan/session/spec/BTW transition through one explicit transition boundary. Preserve the read-only gate unless the user visibly exits or confirms the transition, while retaining /plan off restoration and conversation isolation.

    Regression coverage: Exercise all four commands after entering Plan from Auto, Ask, and Unsafe. Assert the resulting permission mode, advertised toolset, transcript notice, and any later implementation run's mode.

  • [P1] Publish the same canonical plan that execution stores
    internal/tui/plan_command.go:359

    Failure path: Reload a plan file containing two [in_progress]/[doing] rows. SetPlan(items) copies the slice and privately demotes the earlier active item, while reloadPlanFromFile returns the raw two-active slice. CurrentPlan() then disagrees with the sticky panel and editor-authored session event, and the durable bytes remain noncanonical.

    Root cause: Normalization is hidden inside one consumer instead of being part of the parse/accept boundary, so downstream consumers receive different representations of the same accepted input.

    Required outcome: Normalize once before publishing the reload and distribute that exact snapshot to tool memory, panel, durable file, and session context. Whether SetPlan returns the accepted value or the parser normalizes first is an implementation choice; representation identity is the required contract.

    Regression coverage: Reload files with multiple active rows and status aliases, then assert field-for-field agreement among CurrentPlan, panel items, formatted durable content, and next-turn context.

  • [P2] Determine editor no-ops before the lossy parse boundary
    internal/tui/model.go:1463

    Failure path: An agent plan contains leading/trailing whitespace, tabs, CRLF, or another shape normalized by parsePlanFileLines. The user opens the editor and exits without changing a byte. beforeEdit is the pre-serialization structure, while items is the trimmed post-parse structure, so planItemsEqual reports a change, mutates the plan, and appends the false user-authored “I edited the plan file directly” event.

    Root cause: The no-op comparison crosses a lossy serialization boundary. Structural inequality after parsing does not prove that the editor changed the staged file.

    Required outcome: Compare the staged bytes with their pre-editor baseline before parsing, or canonicalize both before and after through exactly the same round trip. Preserve genuine edits and clears, but emit no mutation or user event for unchanged canonical content.

    Regression coverage: Table-test unchanged and one-byte-edited content containing whitespace, tabs, CRLF, multiline values, notes, escaped Notes: lines, and an empty plan. Assert exactly zero or one edit event as appropriate.

  • [P1] Allow redirected Windows ancestors above the protected plan root
    internal/planmode/read_windows.go:116

    Failure path: The user's profile, AppData, or another legitimate config ancestor is a junction, redirected folder, or subst path. openWindowsBaseDir passes the complete absolute storage-base path to NtCreateFile with OBJ_DONT_REPARSE, so Windows rejects the ancestor traversal before the protected plans root is opened. Plan reads, writes, staging, /plan status, and successful update persistence then fail for that supported layout.

    Root cause: The Windows implementation applies the no-reparse boundary to every ancestor needed to reach the user's legitimate config location, rather than to the protected storage root and descendants. This is stricter than the Unix contract and conflates benign ancestor redirection with redirection of an owned security boundary.

    Required outcome: Resolve/open the legitimate parent first, bind it to a directory handle, and no-follow the final protected root plus every descendant relative to that handle. Continue refusing reparse replacement at the protected root and below.

    Regression coverage: Accept junctions at multiple ancestors and a subst path, while refusing a reparse point at the final plan root and intermediate descendants for both read and write.

  • [P2] Prove staged-file ownership before reclaiming Markdown files
    internal/planmode/planmode.go:207

    Failure path: Put an old unrelated notes.md in zero/plan-edit with no companion lock, then invoke /plan open. The sweep treats the .md suffix as sufficient ownership evidence; the platform reclaimer treats the missing/open-failed lock as abandonment and deletes the file. The regression test protects notes.txt, so it does not exercise this path.

    Root cause: Filename extension and age are being used as proof of both ownership and abandonment. Neither identifies a Zero staging invocation, and a missing lock is not positive ownership evidence.

    Required outcome: Reclaim only entries positively identified as Zero-owned staged edits and proven abandoned through a generated-name/ownership marker plus a lock, lease, or equivalent liveness mechanism. Preserve active edits and leave unrelated files untouched.

    Regression coverage: Verify that old unrelated .md files and active staged edits survive, while a positively identified abandoned Zero staging file is reclaimed on Unix and Windows.

  • [P1] Preserve presence when a successful update clears the plan
    internal/tui/plan_command.go:565

    Failure path: After a nonempty plan, call update_plan with plan: []. The tool accepts the call, clears its current plan, and returns a non-nil empty PlanSnapshot. planSnapshotFromResult uses len > 0 as the presence test, so the callback skips the panel update and WritePlan. Tool memory says empty while the panel and durable file retain the old plan, which a later resume can resurrect.

    Root cause: Moving the state-transfer payload from encoded metadata to a typed slice lost the distinction between “successful empty snapshot” and “no snapshot because the call failed or was cancelled.” Length is being used as a presence bit.

    Required outcome: Represent snapshot presence explicitly, or preserve nil versus non-nil semantics, and propagate a successful empty snapshot through panel, durable storage, and session consumers. Do not recover it by rereading the shared mutable tool.

    Regression coverage: Cover nonempty update, empty clear, invalid arguments, cancellation, and a session switch between tool completion and the result callback. Assert the callback publishes only the snapshot carried by that result.

  • [P2] Restore unrelated mainline agent-loop regression coverage
    internal/agent/loop_test.go:3448

    Failure path: This branch removes or weakens the live target's tests for spec-draft name-spoofed control tools, structured tool-error history, aborted-result IsError, and trace context/prefix propagation. Equivalent integration assertions are absent at head, so those security and protocol regressions can return without failing this package's tests.

    Root cause: The feature branch rewrites a broad shared test file while its production agent change only adds typed PlanSnapshot transport. Unrelated mainline assertions were dropped during that rewrite instead of being preserved or deliberately relocated.

    Required outcome: Restore equivalent end-to-end assertions for each removed contract without redesigning unrelated production code. If coverage is relocated, keep the same integration boundary rather than replacing it only with lower-level unit tests.

    Regression coverage: Retain the spoofed spec-tool execution denial, tool-result error propagation, aborted-placeholder error status, and OnContext/trace prefix-hash agreement assertions present at the live target.

  • [P2] Detect a newer durable plan before committing an editor snapshot
    internal/planmode/planmode.go:334

    Failure path: Instance A stages plan A. Instance B writes newer plan B for the same workspace/session. A exits its editor without changing the staged bytes. CommitStagedEdit unconditionally writes A over B before the TUI performs its no-op comparison, so the newer plan is lost and the completion path may report no edit.

    Root cause: The staged edit records content but not the durable version/content it was based on. Commit is an unconditional last-writer-wins operation, and no-op detection occurs after the destructive write.

    Required outcome: Bind staging to a baseline version or content hash and compare before commit. An unchanged stale editor must not overwrite a newer durable snapshot; an intentional edit concurrent with another update should follow an explicit conflict policy rather than silently losing either value.

    Regression coverage: Stage A, persist B from another instance, then cover both unchanged and intentionally edited A. Assert unchanged A preserves B and concurrent real edits produce the chosen explicit conflict behavior.

  • [P2] Restore the latest hidden-parent plan on BTW fallback
    internal/tui/btw.go:155

    Failure path: BTW captures parentPlanItems at entry. While the side surface is visible, a pending hidden-parent run completes and its routed planUpdateMsg updates the parent model. If the durable plan is missing or reload fails on return, leaveBTW installs the entry-time slice into the shared tool and panel, overwriting the newer hidden-parent result.

    Root cause: The fallback snapshot is detached from the hidden parent after entry, even though routed messages continue mutating that parent. Durable storage is treated as the only newer source, so its failure exposes the stale capture.

    Required outcome: Keep the fallback synchronized with routed parent plan updates or derive it from the latest hidden-parent state at return. Preserve BTW side isolation and let a successfully loaded valid durable plan continue to take precedence.

    Regression coverage: Enter BTW with a parent plan, route a newer parent planUpdateMsg, and return under successful reload, missing-file, and read-error conditions. Assert the latest valid parent plan survives and side state never leaks.

  • [P2] Isolate /plan on tests from the real session store
    internal/tui/plan_mode_test.go:21

    Failure path: The new /plan on path calls ensureActiveSession, but these tests construct newModel without a test SessionStore or complete data-root redirection. They write persistent sessions under the developer's real user-data directory and fail before entering plan mode when HOME/XDG data is read-only.

    Root cause: A new production persistence dependency was added to a previously in-memory command path without updating legacy tests to inject that dependency. The tests therefore depend on ambient platform-specific home/data resolution.

    Required outcome: Give every affected model a test-owned session store and redirect any remaining config/data roots across Linux, macOS, and Windows. Preserve production session creation on /plan on.

    Regression coverage: Run the focused plan-entry tests with a read-only real home and sentinel-protected user-data locations. Assert all writes remain inside the injected test root.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified at 8c25c35. The three commits since my last pass do not touch the paths below. Three of my items are closed and four stay open; jatmn raised all four open ones at this head and I agree with him on each, so this stays at request changes.

Closed:

  • Windows staging containment through resolvePhysical. Reverting both call sites to filepath.EvalSymlinks fails the two junction tests on their property lines (planmode_windows_test.go:33, :43, :65). Junction chains, a junction at the parent and an upper-cased root are all refused. Correction to my earlier wording: two tests with three assertions, not three tests.
  • The reparse backstop and its coverage: the junction tests run on an unelevated box; the skips are the symlink-privilege ones.
  • Queued prompts are held in plan mode. Removing the planModeBlocksContinuations clause fails plan_command_test.go:1059, and every launch path goes through launchQueuedMessageIfReady.

Open, driven on both trees from Auto and then /plan on:

  • /spec, /btw, /new and /resume still drop plan mode. On this head each returns the session to the pre-plan mode with only the footer label changing and nothing in the transcript; base keeps the read-only gate on all four and refuses /spec with "is unavailable in plan mode". The base gate list has commandSpec and this one does not, and the exits are pinned by name in your own tests, so I read it as a deliberate choice. It still widens authority on a session switch, which is jatmn's P1 at plan_command.go:147: five call sites assemble their own exit and there is no single visible transition.
  • Reload hands raw items to the caller and panel and enforced items to the tool. A file with two [doing] steps gives the tool [completed in_progress pending] and the panel [in_progress in_progress pending], with no demotion notice. That is jatmn's canonical-plan P1.
  • A no-op editor session claims a user edit. Driven end to end through update_plan, WritePlan, StageForEditor and CommitStagedEdit with no keystroke, six of seven whitespace shapes append a role=user "I edited the plan file directly" event and the "Reloaded the edited plan." row. Correction to my earlier "five of seven". jatmn has this as P2; I keep it in this round because it writes a false user-authored event into the session.
  • A benign reparse point above the plans root kills plan I/O on Windows. A junction one or three levels above AppData, or a subst drive, makes WritePlan, ReadPlan and StageForEditor all refuse with "plan storage root ... is a symlink", so /plan on enters with a reload error and every successful update_plan logs a write error. OBJ_DONT_REPARSE is whole-path; read_unix.go:25 says a symlinked ~/.config above the root is fine, and Windows should match it by resolving the legitimate parent and no-following only from the root down. That is jatmn's P1 at read_windows.go:116.

Note only: the reparse guard at planmode.go:286 is unpinned. Stubbing pathIsReparsePoint to false leaves TestVerifyPrivateDirectoryRejectsJunction passing, because the refusal comes from the IsDir check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants