LearnNewsExamplesServices
Frontmatter
id11145
titleReject sync_all MCP tool when caller's working tree is not on dev branch
stateClosed
labels
enhancementaiarchitecturemodel-experience
assigneesneo-opus-4-7
createdAtMay 10, 2026, 8:57 PM
updatedAtMay 10, 2026, 9:21 PM
githubUrlhttps://github.com/neomjs/neo/issues/11145
authorneo-opus-4-7
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 10, 2026, 9:21 PM

Reject sync_all MCP tool when caller's working tree is not on dev branch

Closedenhancementaiarchitecturemodel-experience
neo-opus-4-7
neo-opus-4-7 commented on May 10, 2026, 8:57 PM

Premise

gh-workflow MCP server exposes sync_all (per toolService.mjs:36SyncService.runFullSync). The OpenAPI tool description claims dev-branch-only. Description-as-policy is empirically insufficient — @neo-gemini-3-1-pro (shared-checkout Antigravity harness without worktree isolation) violates the dev-branch constraint 5+ times per day, polluting feature branches with sync writes and creating stale-branch + chore-sync race conditions (#11133 pattern, 5x this session alone).

Operator framing 2026-05-10: "the tool should REJECT when not being on dev branch. might be a way out."

Prescription

Branch-check guard at the tool-surface boundary (not inside SyncService library — daemons + build-script callers legitimately bypass the agent-callable tool):

// In ai/services/github-workflow/toolService.mjs, replace the bound method:
sync_all: async (...args) => {
    const {execFile}    = await import('child_process');
    const {promisify}   = await import('util');
    const execFileAsync = promisify(execFile);
    const {stdout}      = await execFileAsync('git', ['branch', '--show-current']);
    const branch        = stdout.trim();
    if (branch !== 'dev') {
        throw new Error(
            `sync_all REJECTED: working-tree is on branch '${branch}', not 'dev'. ` +
            `Sync writes go to resources/content/ which would pollute the non-dev branch. ` +
            `Switch to dev (and let any in-flight feature-branch work continue separately), ` +
            `or run sync via daemon (PrimaryRepoSyncService, scheduled) which enforces dev at spawn.`
        );
    }
    return SyncService.runFullSync(...args);
}

Why tool-boundary, not library-boundary

Caller Path Branch context
Agent → MCP sync_all through toolService.mjs mapping needs check (this is the violation surface)
Daemon → PrimaryRepoSyncService.runTask calls SyncService.runFullSync directly bypasses tool surface — daemon is spawned in canonical (dev); legitimate path
Release pipeline → buildScripts/release/publish.mjs calls GH_SyncService.runFullSync() directly bypasses tool surface — build-script context with explicit branch context

Putting the check inside SyncService.runFullSync would block legitimate non-tool callers. Tool-boundary check leaves library clean.

Why not remove the tool entirely

Operator's first vote was "daemons should be in charge for sync calls. the mcp server should not keep the tool." Pivoted to REJECT-alternative because:

  • Preserves operator escape-hatch (manual sync from canonical-on-dev for emergencies)
  • Smaller scope (1 file change vs 4)
  • Mechanically reversible (remove the check)
  • Tool description preserves discoverability + "dev-branch-only" mandate is now ENFORCED, not just declarative

Avoided Traps

Considered Rejected Rationale
Check inside SyncService.runFullSync Reject blocks legitimate daemon + build-script callers; library should stay agnostic
Remove sync_all entirely Reject (per pivot) loses operator escape-hatch + larger surface change
Soft-warn + log instead of throw Reject description-as-policy already empirically insufficient (5+/day violations); throw is the mechanical gate
Auto-checkout-dev before sync Reject side-effect on caller's working tree; loses any uncommitted work; surprise-pattern
Allow operator-override flag (--allow-non-dev) Defer to follow-up YAGNI today; can add when first legitimate non-dev use case surfaces

Acceptance Criteria

  • toolService.mjs sync_all mapping wraps with branch-check guard
  • Throw message names current branch + suggests remediation paths
  • Unit test: when on dev, sync_all proxies to runFullSync; when on non-dev, throws with branch name in message
  • OpenAPI sync_all tool description updated to reflect mechanical enforcement (no longer just guideline)
  • No change to SyncService.runFullSync library surface (daemon + build-script callers unaffected)

Empirical Anchors

  • 2026-05-10 graph-wipe incident — same family of "claimed-but-not-enforced" substrate (#11140 + #11141 + this)
  • #11133 — workflow-fragility friction-gold (stale-branch + chore-sync 5x/session pattern); this PR addresses one of the recurring violation paths
  • @tobiu (2026-05-10): "even when the tool description strictly says dev branch only, gemini violates this 5+ times every single day."
  • Today's PR #11143 race — Gemini ran sync against my feature branch + chore: ticket sync commits landed on it, requiring force-push cleanup. Authorship-boundary cross + branch-pollution; both surfaces this rejection would catch.

— @neo-opus-4-7

tobiu referenced in commit 8358201 - "feat(github-workflow): mechanically REJECT sync_all when caller not on dev branch (#11145) (#11146) on May 10, 2026, 9:21 PM
tobiu closed this issue on May 10, 2026, 9:21 PM