Skip to content

fix(vm-agent): block auto-commit pushes to default branch#1672

Open
simple-agent-manager[bot] wants to merge 3 commits into
mainfrom
sam/retry-default-branch-remediation-1bsqej
Open

fix(vm-agent): block auto-commit pushes to default branch#1672
simple-agent-manager[bot] wants to merge 3 commits into
mainfrom
sam/retry-default-branch-remediation-1bsqej

Conversation

@simple-agent-manager

@simple-agent-manager simple-agent-manager Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Block VM-agent auto-commit pushes that would land on the project default branch (main/master/etc.)
  • Add DefaultBranch field threaded from control plane → VM agent to correctly distinguish the project default branch from the workspace checkout branch
  • Preserve existing successful task completion behavior for correctly checked-out task branches
  • Include regression tests proving default-branch pushes are blocked and output-branch pushes still work

Problem

When a SAM task completes, the VM agent auto-commits uncommitted changes and pushes via gitPushWorkspaceChanges. Workspaces check out the project's defaultBranch (e.g. "main"), and the agent (Claude Code/Codex) is expected to switch to the task output branch during work. If the agent never switches branches, git push --set-upstream origin HEAD pushes directly to main/master — contaminating the default branch with task-specific changes.

Solution

1. Push guard in gitPushWorkspaceChanges (server.go)

After auto-committing but before pushing, the guard compares HEAD against the project's default branch. If HEAD is still on the default branch, the push is blocked with a descriptive error. Changes are committed locally (SHA preserved in the error message) but not pushed.

2. DefaultBranch field (go-specialist finding fix)

The initial implementation compared HEAD against Branch (the checkout branch). A go-specialist review identified a CRITICAL false-positive: in dispatch/retry scenarios, the workspace is checked out directly on the task output branch, so Branch == output_branch. Comparing HEAD against Branch would incorrectly block legitimate pushes.

Fix: Added a separate DefaultBranch field that carries the project's actual default branch. The guard compares against DefaultBranch (falling back to Branch for backwards compatibility with older control planes).

3. Control plane threading

defaultBranch is sent from the task runner (workspace-steps.ts) through createWorkspaceOnNode (node-agent.ts) to the VM agent's createWorkspaceRequest.

Files Changed

File Change
packages/vm-agent/internal/server/server.go DefaultBranch field on WorkspaceRuntime; workspaceDefaultBranch() helper with fallback; shouldBlockDefaultBranchPush() predicate; guard in gitPushWorkspaceChanges
packages/vm-agent/internal/server/workspaces.go DefaultBranch on createWorkspaceRequest; wired through createWorkspaceRuntimeOptions
packages/vm-agent/internal/server/workspace_routing.go DefaultBranch on workspaceRuntimeOpts; propagated in upsertWorkspaceRuntime
packages/vm-agent/internal/server/git_push_guard_test.go 27 subtests: unit tests for workspaceDefaultBranch, shouldBlockDefaultBranchPush, integration tests, and dispatch/retry scenarios with DefaultBranch≠Branch
apps/api/src/services/node-agent.ts defaultBranch parameter on createWorkspaceOnNode
apps/api/src/durable-objects/task-runner/workspace-steps.ts Thread defaultBranch from task config to workspace creation

Test plan

  • 27 Go subtests covering all guard scenarios (all pass)
  • shouldBlockDefaultBranchPush predicate: main, master, develop, production, output branch, feature branch, empty
  • workspaceDefaultBranch helper: DefaultBranch precedence, Branch fallback, empty, unknown workspace
  • Integration tests: Server struct with workspace state, combined helper + predicate
  • Dispatch/retry scenario: Branch="sam/task-123", DefaultBranch="main" — HEAD on output branch NOT blocked, HEAD on main IS blocked
  • Legacy backwards compatibility: no DefaultBranch set → falls back to Branch
  • Go build passes
  • TypeScript typecheck passes

Agent Preflight (Required)

  • Preflight completed before code changes

Classification

  • cross-component-change
  • business-logic-change

External References

N/A: Internal bug fix based on observed production behavior (task auto-commits pushing to main). No external API changes.

Codebase Impact Analysis

  • packages/vm-agent/internal/server/WorkspaceRuntime struct, gitPushWorkspaceChanges, workspace creation/routing
  • apps/api/src/services/node-agent.tscreateWorkspaceOnNode parameter addition
  • apps/api/src/durable-objects/task-runner/workspace-steps.ts — Threading defaultBranch from task config

Documentation & Specs

N/A: Internal bug fix. No user-facing API surface change (the defaultBranch field is an internal wire format addition).

Constitution & Risk Check

  • Principle XI (No Hardcoded Values): No hardcoded branch names — default branch comes from project config, with Branch fallback for backwards compatibility.
  • Risk: Fail-open design — if DefaultBranch and Branch are both empty (unknown workspace), the guard is skipped with a warning log rather than blocking. This preserves existing behavior for edge cases.

Specialist Review Evidence

Reviewer Status Notes
go-specialist ADDRESSED CRITICAL false-positive finding → added DefaultBranch field to distinguish project default from checkout branch
security-auditor ADDRESSED fail-open observability gap → added slog.Warn for unknown workspace case

Staging Verification (REQUIRED for all code changes — merge-blocking)

  • Staging deployment green — Not deployed per DO NOT MERGE constraint
  • Live app verified via Playwright — Deferred per DO NOT MERGE constraint
  • Existing workflows confirmed working — Deferred per DO NOT MERGE constraint
  • New feature/fix verified on staging — Deferred per DO NOT MERGE constraint
  • Infrastructure verification completed — N/A: VM agent binary changes require node deletion + fresh provisioning for staging test. Will be verified when PR is approved for merge.

Staging Verification Evidence

This PR is marked DO NOT MERGE per task constraints. Staging verification will be performed when the PR is approved for merge by a human reviewer.

End-to-End Verification (Required for multi-component changes)

Data flow trace:

  1. Task runner creates workspace → workspace-steps.ts:createWorkspace() passes defaultBranch: state.config.defaultBranch || 'main'
  2. node-agent.ts:createWorkspaceOnNode() includes defaultBranch in HTTP body
  3. → VM agent workspaces.go:handleCreateWorkspace() parses defaultBranch from request
  4. → Stored in WorkspaceRuntime.DefaultBranch via workspace_routing.go:upsertWorkspaceRuntime()
  5. On task completion → server.go:gitPushWorkspaceChanges() calls workspaceDefaultBranch() (prefers DefaultBranch, falls back to Branch)
  6. shouldBlockDefaultBranchPush() compares resolved default branch against HEAD
  7. → If HEAD == default branch, push is blocked with descriptive error

⚠️ DO NOT MERGE — per task constraints, this PR is for review only.

🤖 Generated with Claude Code

raphaeltm and others added 2 commits July 25, 2026 07:57
When a task completes, makeTaskCompletionCallback calls
gitPushWorkspaceChanges which runs `git push --set-upstream origin HEAD`.
Since workspaces always check out the project's default branch (e.g.
"main"), if the agent never switches to the task output branch, the push
lands directly on main/master — potentially triggering production deploys
with unreviewed changes.

Add a guard in gitPushWorkspaceChanges that compares HEAD against the
workspace's checkout branch (WorkspaceRuntime.Branch). When they match,
the push is blocked with actionable diagnostics explaining that the agent
should have switched to the output branch. Local commits are preserved.

When the workspace is not found (empty checkout branch), the guard is
skipped to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…fail-open warning

- Extract shouldBlockDefaultBranchPush() as a pure, testable predicate
  so tests exercise the real production code, not a re-derived copy
- Add slog.Warn when checkout branch cannot be determined (fail-open
  path is now observable in production logs)
- Document scope limitation: guard covers SAM's auto-commit path only,
  not agent-initiated git pushes
- Rewrite tests to call the real predicate and integration-test the
  full Server → workspaceCheckoutBranch → shouldBlockDefaultBranchPush
  chain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@codspeed-hq

codspeed-hq Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing sam/retry-default-branch-remediation-1bsqej (f3b9fca) with main (44adc7e)

Open in CodSpeed

Address false-positive blocking identified by go-specialist review:
when a workspace is checked out directly on the task output branch
(dispatch/retry scenarios), the guard was comparing HEAD against Branch
(the checkout branch), which would incorrectly block legitimate pushes.

- Add DefaultBranch field to WorkspaceRuntime, createWorkspaceRequest,
  and workspaceRuntimeOpts structs
- Rename workspaceCheckoutBranch → workspaceDefaultBranch with
  DefaultBranch-first, Branch-fallback resolution
- Thread defaultBranch from task-runner workspace-steps through
  node-agent createWorkspaceOnNode to the VM agent
- Add TestGitPushGuardWithDefaultBranch covering dispatch/retry
  scenario (Branch≠DefaultBranch), legacy fallback, and same-default
- Update TestWorkspaceDefaultBranch for new field precedence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
32.4% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

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.

1 participant