Skip to content

Add Go M0 write tools#48

Merged
Vasanthdev2004 merged 3 commits into
mainfrom
feat/go-m0-write-tools
Jun 4, 2026
Merged

Add Go M0 write tools#48
Vasanthdev2004 merged 3 commits into
mainfrom
feat/go-m0-write-tools

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add Go core write tools: write_file, edit_file, apply_patch
  • add in-memory update_plan tool and CoreTools/CoreWriteTools registry helpers
  • add prompt permission gating through Registry.RunWithOptions
  • add workspace target-path resolution for files that may not exist yet

Tests

  • bun run typecheck
  • bun test ./tests --timeout 15000
  • bun run build
  • bun run smoke:build

Local blockers

  • go/go fmt are not installed on this Windows machine, so go test ./..., go build ./cmd/zero, and gofmt could not run locally
  • bun run smoke:go fails locally because build:go cannot run; CI is configured with actions/setup-go and will run Go test/build/smoke

Summary by CodeRabbit

  • New Features

    • Added workspace file tools: create/overwrite files, in-place exact-string edits (first/all), and apply unified-diff patches.
    • Added an in-memory task planning tool to store, format, clear, and inspect plans.
  • Security & Reliability

    • Stronger workspace path checks and symlink-aware resolution; safer overwrite, permissions gating, and match validations.
  • Tests

    • Expanded tests covering tools, permission gating, edge cases, and filesystem safety.

@Vasanthdev2004
Vasanthdev2004 marked this pull request as ready for review June 4, 2026 08:28
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Typecheck: bun run typecheck
  • [pass] Tests: bun run test
  • [pass] Build: bun run build
  • [pass] Smoke build: bun run smoke:build

Scope

Head: db22668c6cea
Changed files (10): internal/tools/apply_patch.go, internal/tools/args.go, internal/tools/edit_file.go, internal/tools/plan_tool_test.go, internal/tools/registry.go, internal/tools/types.go, internal/tools/update_plan.go, internal/tools/workspace.go, internal/tools/write_file.go, internal/tools/write_tools_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

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: 59042617-22a5-4050-ae28-315146930c48

📥 Commits

Reviewing files that changed from the base of the PR and between 1f270b2 and db22668.

📒 Files selected for processing (1)
  • internal/tools/workspace.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/tools/workspace.go

Walkthrough

Adds write-capable tools (write_file, edit_file, apply_patch) and an in-memory update_plan tool; introduces permission-gated registry execution, workspace path resolution/recheck to prevent symlink traversal, and comprehensive unit tests.

Changes

Write-capable and planning tools

Layer / File(s) Summary
Permission and execution infrastructure
internal/tools/registry.go, internal/tools/types.go, internal/tools/args.go
Registry adds RunWithOptions and permission gating; promptSafety helper and stringArgWithEmpty refactor introduced.
Workspace path resolution
internal/tools/workspace.go
resolveWorkspaceTargetPath and recheckWorkspaceWriteTarget resolve targets conservatively and disallow symlink traversal on existing path components.
Write file tool
internal/tools/write_file.go, internal/tools/write_tools_test.go
write_file creates/overwrites files with overwrite flag, ensures parent dirs, rechecks workspace target, and reports byte counts; tests cover creation, overwrite protection, empty content, type errors, out-of-workspace and symlink-parent rejection.
Edit file tool
internal/tools/edit_file.go, internal/tools/write_tools_test.go
edit_file does exact byte-string replacement with optional replace_all; validates match count, rechecks write target, writes with 0644, and tests replacements, deletions, missing/ambiguous matches, and bulk updates.
Apply patch tool
internal/tools/apply_patch.go, internal/tools/write_tools_test.go
apply_patch parses unified-diff headers, validates extracted paths, rechecks per-target write eligibility, writes temp patch file, and runs git apply; tests cover diff application, CRLF normalization, symlink-target rejection, and outside-workspace cwd rejection.
Plan management tool
internal/tools/update_plan.go, internal/tools/plan_tool_test.go
update_plan stores validated PlanItem slices, returns formatted output and defensive copies, and supports ClearPlan; tests verify parsing, status validation, formatting, defensive copy behavior, and clearing state.
Tool registry assembly and integration
internal/tools/registry.go, internal/tools/write_tools_test.go
CoreWriteTools and CoreTools expose the new tools; integration tests assert tool names and safety (side-effect/permission) and registry permission-gating behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Registry
  participant WriteFileTool
  participant FS
  Client->>Registry: RunWithOptions(write_file, args, PermissionGranted=true)
  Registry->>WriteFileTool: Run(ctx, args)
  WriteFileTool->>WriteFileTool: validate args, resolve path
  WriteFileTool->>FS: ensure parent dirs (MkdirAll)
  WriteFileTool->>FS: write file (WriteFile)
  WriteFileTool-->>Registry: okResult
  Registry-->>Client: okResult
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Gitlawb/zero#47: Extends the same core tooling infrastructure and permission prompting changes.

Suggested reviewers

  • anandh8x
  • gnanam1990
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add Go M0 write tools' directly and accurately summarizes the main change: introducing three new write tools (write_file, edit_file, apply_patch) plus related infrastructure.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/go-m0-write-tools

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

@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: 3

🧹 Nitpick comments (1)
internal/tools/plan_tool_test.go (1)

9-58: ⚡ Quick win

Add a direct test for ClearPlan() state reset behavior.

The suite validates store/format and status errors, but not reset semantics on this stateful tool.

Suggested test addition
 func TestUpdatePlanToolRejectsInvalidStatus(t *testing.T) {
@@
 }
+
+func TestUpdatePlanToolClearPlanResetsState(t *testing.T) {
+	tool := NewUpdatePlanTool()
+	_ = tool.Run(context.Background(), map[string]any{
+		"plan": []any{
+			map[string]any{"id": "1", "content": "Step", "status": "pending"},
+		},
+	})
+
+	tool.ClearPlan()
+	if got := tool.CurrentPlan(); len(got) != 0 {
+		t.Fatalf("expected empty plan after ClearPlan, got %d items", len(got))
+	}
+}
🤖 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/plan_tool_test.go` around lines 9 - 58, Add a new unit test
that verifies ClearPlan() resets internal state: create an UpdatePlanTool via
NewUpdatePlanTool(), call Run(...) with a multi-item "plan" and assert
CurrentPlan() length is >0, then call tool.ClearPlan() and assert
tool.CurrentPlan() returns an empty slice (len == 0) and that subsequent calls
to Run or formatting reflect the cleared state; reference NewUpdatePlanTool,
Run, CurrentPlan, and ClearPlan in the test to locate the code to exercise and
assert reset semantics.
🤖 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/tools/apply_patch.go`:
- Around line 60-63: The deferred os.Remove(patchPath) and the tempFile.Close()
call inside the WriteString error branch currently ignore returned errors;
update internal/tools/apply_patch.go so the defer explicitly handles the remove
error (e.g., defer func() { _ = os.Remove(patchPath) }()) and ensure
tempFile.Close() return value is handled or discarded (e.g., if err2 :=
tempFile.Close(); err2 != nil { /* log or ignore: _ = err2 */ }) in the
WriteString error branch where tempFile is closed before returning from the
errorResult("Error applying patch: "+err.Error()) path; keep the existing
errorResult return behavior but do not leave Close/Remove errors unhandled.

In `@internal/tools/args.go`:
- Around line 21-24: The current check mixes type and emptiness so when
allowEmpty==true a non-string still returns "must be a non-empty string"; change
the logic in the function in internal/tools/args.go that performs the text, ok
:= value.(string) assertion to first return a clear type error (e.g., "%s must
be a string") if ok is false, then only perform the emptiness check if
allowEmpty is false and return "%s must be a non-empty string" when text == "";
reference the variables text, ok, key and allowEmpty to locate and update the
two separate validation branches.

In `@internal/tools/workspace.go`:
- Around line 95-131: The current resolution code builds a safe-looking path
(using variables existing, missingSegments and filepath.EvalSymlinks) but can be
racy if missing parents are created or swapped to symlinks later; fix by adding
a second containment/symlink check immediately before any final write/apply
operation (i.e., in the callers that perform writes) that re-evaluates symlinks
(call filepath.EvalSymlinks on the final path or perform an os.Lstat walk
refusing ModeSymlink) and verifies the resulting path is still inside root (same
logic that checks relative against root), or alternatively change this resolver
to return a secure descriptor/walk that refuses symlinks for each path segment
so callers can safely write without a TOCTOU escape. //

---

Nitpick comments:
In `@internal/tools/plan_tool_test.go`:
- Around line 9-58: Add a new unit test that verifies ClearPlan() resets
internal state: create an UpdatePlanTool via NewUpdatePlanTool(), call Run(...)
with a multi-item "plan" and assert CurrentPlan() length is >0, then call
tool.ClearPlan() and assert tool.CurrentPlan() returns an empty slice (len == 0)
and that subsequent calls to Run or formatting reflect the cleared state;
reference NewUpdatePlanTool, Run, CurrentPlan, and ClearPlan in the test to
locate the code to exercise and assert reset semantics.
🪄 Autofix (Beta)

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: 792d9e70-2d98-493a-a99d-752b34c7411e

📥 Commits

Reviewing files that changed from the base of the PR and between c9d0856 and 57c5b1f.

📒 Files selected for processing (10)
  • internal/tools/apply_patch.go
  • internal/tools/args.go
  • internal/tools/edit_file.go
  • internal/tools/plan_tool_test.go
  • internal/tools/registry.go
  • internal/tools/types.go
  • internal/tools/update_plan.go
  • internal/tools/workspace.go
  • internal/tools/write_file.go
  • internal/tools/write_tools_test.go

Comment thread internal/tools/apply_patch.go Outdated
Comment thread internal/tools/args.go
Comment thread internal/tools/workspace.go

@anandh8x anandh8x 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.

What's good

  • Defense-in-depth on writes: every write tool resolves via resolveWorkspaceTargetPath (internal/tools/workspace.go:72-126) for non-existent targets, and then runs recheckWorkspaceWriteTarget (internal/tools/workspace.go:128-191) immediately before the actual write. The recheck walks the path segment by segment and refuses any segment whose Lstat shows os.ModeSymlink, so a symlink parent is rejected at the latest possible moment before os.WriteFile runs. This is the TOCTOU mitigation CodeRabbit pushed for, and it's now in place across write_file, edit_file, and apply_patch.
  • apply_patch does the recheck twice — once during validatePatchPaths and again right before exec.CommandContext("git", "apply", ...) — so the git apply subprocess only ever sees a path tree that has just been re-validated (internal/tools/apply_patch.go:69-72). The temp patch file is defer-cleaned (internal/tools/apply_patch.go:55-56) and both the WriteString and Close errors are checked.
  • Registry.RunWithOptions (internal/tools/registry.go:36-54) is a clean three-way switch: PermissionAllow runs unconditionally, PermissionPrompt requires options.PermissionGranted, anything else (incl. PermissionDeny) is refused. Defaulting Run to RunWithOptions(ctx, name, args, RunOptions{}) keeps existing call sites safe by default while letting the TUI / agent loop pass a grant flag.
  • edit_file enforces uniqueness by default and only allows non-unique edits behind an explicit replace_all: true, with a clear error message that quotes the occurrence count. Empty new_string is allowed and works as a real deletion (tested). write_file defaults to refusing overwrites and requires overwrite: true to replace.
  • stringArgWithEmpty (internal/tools/args.go:9-23) now separates the type check from the emptiness check, so a caller passing content: 42 gets content must be a string instead of the misleading content must be a non-empty string — CodeRabbit's earlier finding, fixed.
  • update_plan uses a pointer receiver, returns a defensive copy from CurrentPlan() (internal/tools/update_plan.go:63-65), and the test suite verifies that mutating the returned slice doesn't mutate internal state. The follow-up commit db22668 adds TestUpdatePlanToolClearPlanResetsState for the reset semantics CodeRabbit flagged.
  • Test coverage: prompt blocking without grant, prompt running with grant, create/overwrite/refuse, empty content, type errors, workspace rejection, symlink-parent rejection, edit uniqueness, replace_all, full git apply round-trip, and symlink rejection inside patch paths. CI is green on all four matrices and CodeRabbit reports SUCCESS on the latest SHA.

Observations (non-blocking)

  1. TOCTOU between the recheck and the actual write. write_file.go:64-67 and edit_file.go:78-83 do recheckWorkspaceWriteTargetos.MkdirAll / os.WriteFile non-atomically. The recheck catches an existing symlink, but if <root>/parent doesn't exist yet, an attacker (or another process) who creates it as a symlink to e.g. /etc between the recheck and the MkdirAll call would have the write land outside the workspace. The canonical fix is the same write-temp-then-rename pattern PR #40's ZeroMcpPermissionStore uses: write to <dir>/.zero.tmp.<rand>, fsync, then rename onto the target. On POSIX this is atomic; on Windows the rename fails if the destination exists (which is the desired behaviour). Adopting it for write_file would close this window for free; the same pattern would let edit_file build the new contents in a temp file and rename over the original, which would also give you a clean "the file changed under us" detection.

  2. apply_patch parses patch paths line-by-line, not via a real parser. patchPathsFromLine (internal/tools/apply_patch.go:121-135) recognises diff --git, --- , and +++ prefixes and strips a//b/. A unified diff with quoted filenames containing spaces (e.g. +++ "b/path with spaces.txt") or with backslash-escaped paths won't be parsed correctly and the workspace check would either miss paths or fail to recognise that a path is unsafe. A test using a git diff output on a path with spaces would cover this; if you want to keep the manual parser, escaping-aware splitting on the diff header would be the next step. (Note: this isn't a vulnerability per se — git apply itself enforces the workspace boundary via cwd — but the pre-flight check could be bypassed, which is more of a correctness issue for the diagnostic.)

  3. Hard dependency on git apply is not documented. apply_patch shells out to git apply --whitespace=nowarn. If the runtime doesn't have git on PATH (or the user disables it), the tool errors with Error applying patch: exec: "git": executable file not found in $PATH — informative but unexplained. A line in the tool description ("Requires git on PATH.") would set expectations; an explicit exec.LookPath("git") check in NewApplyPatchTool (or at the top of Run) with a friendlier error would be even better.

  4. update_plan allows duplicate IDs across plan items. parsePlanItems (internal/tools/update_plan.go:60-93) doesn't check for id uniqueness, so two items with id: "1" are accepted and rendered. If the TUI or downstream consumers key on id, this would silently break them. A uniqueness assertion (or a doc comment saying IDs may repeat) would prevent surprises.

  5. Run vs RunWithOptions asymmetry is a footgun. The default Run never grants permissions, so any PermissionPrompt tool fails by default; the caller has to know to switch to RunWithOptions with PermissionGranted: true. This is the right safe default, but it's easy to write a TUI that calls Run for everything and then is surprised when write tools never work. A short note in the Run doc comment (or a lint-style test that asserts every Run call site is paired with a permission check upstream) would help.

  6. recheckPatchWriteTargets doesn't validate git apply's actual output paths against the workspace. The pre-checks look at the --- a/x and +++ b/x headers, but git apply can create new files, modify existing ones, and delete files. A patch that includes rename from / rename to or copy from / copy to lines isn't enumerated by patchPathsFromLine and therefore won't be rechecked. Same caveat as #2git apply itself respects command.Dir = applyRoot, so the actual writes land inside the workspace — but the pre-flight check is incomplete for non-trivial patches.

No blockers. The TOCTOU window in #1 is the one I'd most want to see closed in a follow-up; the rest are correctness / documentation polish.

@Vasanthdev2004
Vasanthdev2004 merged commit e748206 into main Jun 4, 2026
6 checks passed
@Vasanthdev2004
Vasanthdev2004 deleted the feat/go-m0-write-tools branch June 28, 2026 08:27
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.

2 participants