Skip to content

fix: enforce project_ref scope on destructive branch tools - #351

Open
mansueli wants to merge 1 commit into
mainfrom
fix/branch-scope-enforcement
Open

fix: enforce project_ref scope on destructive branch tools#351
mansueli wants to merge 1 commit into
mainfrom
fix/branch-scope-enforcement

Conversation

@mansueli

Copy link
Copy Markdown
Member

Summary

When the MCP server is scoped with project_ref / projectId, create_branch and list_branches already inject that project ID. Destructive branch tools (delete_branch, merge_branch, reset_branch, rebase_branch) only accepted a caller-controlled branch_id and forwarded it to the Management API with no check that the branch’s parent_project_ref matched the scoped project.

That breaks the documented project-scoping guarantee: a client scoped to Project A could mutate a development branch belonging to Project B if both were covered by the same org OAuth grant and the caller knew the foreign branch_id.

Fix

Before any destructive branch mutation, if the server is project-scoped:

  1. List branches for the configured project
  2. Allow the operation only if branch_id matches a branch id or project_ref under that project
  3. Otherwise reject with a clear error

Unscoped servers keep existing org-wide behavior.

Test plan

  • Unit: project-scoped MCP rejects foreign-project branch_id for delete/merge/reset/rebase and does not delete the foreign branch
  • Unit: project-scoped MCP still allows delete for a branch of the scoped project
  • Existing branch tool unit tests still pass (create / list / merge / reset / rebase / read-only)
  • CI green on this PR

Notes

  • GET /v1/branches/{branch_id_or_ref} returns BranchDetailResponse without parent_project_ref, so ownership is validated via the project’s branch list (same filter as list_branches).
  • No production Management API endpoints were changed; this is MCP-layer containment only.

When the MCP server is configured with projectId / project_ref, create_branch
and list_branches already inject that project, but delete_branch, merge_branch,
reset_branch, and rebase_branch accepted any branch_id and forwarded it to the
Management API without checking parent_project_ref.

Before mutating, resolve the scoped project's branches and require the target
branch id (or project_ref) to belong to that project. Unscoped servers keep
org-wide behavior. Adds regression tests for cross-project rejection and
same-project delete.
@mansueli
mansueli requested a review from a team as a code owner July 30, 2026 14:39

@barryroodt barryroodt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the update @mansueli, but I don't think this is ready yet. The guard closes the reported foreign-branch path for a parent-scoped server, but it assumes every projectId identifies a branching parent. The server also accepts a development branch's project_ref as its scope.

That assumption produces two failures: a branch-scoped server rejects its own branch, while a parent-scoped server authorizes the default production ref through the same predicate.

I'd resolve the scope once in src/tools/branching-tools.ts, behind one scoped branching helper shared by all four handlers. If BranchingOperations cannot supply the required ownership facts, extend its interface in src/platform/types.ts and implement the Management API lookup in src/platform/api-platform.ts; authorization should remain in branching-tools.ts.

Then update src/server.test.ts and test/mocks.ts to include the default branch row and assert that every destructive platform method remains uncalled after rejection.

return;
}

const branches = await branching.listBranches(projectId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

listBranches(projectId) answers “which branch rows have this parent?”, so it works only when projectId identifies a parent project.

When the server is scoped to a branch's own project_ref, this call returns no children and every destructive tool rejects that branch. In the parent-scoped case, the returned list includes a default row with project_ref === projectId and is_default === true; the predicate therefore treats the production ref as a valid development branch. I reproduced both paths, including reset_branch reaching its platform mutation for the default ref.

Can we move this behind an explicit scope model? Parent scope should allow matching non-default children. Branch scope should allow only itself. Unscoped behavior should remain unchanged.

The direct branch-detail response lacks parent_project_ref and is_default, so replacing this list call with that endpoint alone will not prove ownership.

parent_project_ref: projectB.id,
});

// Also create a same-project branch so list_branches is non-empty for A

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These fixtures call the low-level createBranch helper, which creates only a non-default row. The normal mocked create_branch flow also creates a default row with project_ref === parent_project_ref === projectId. That is the row which exposes the production-ref hole above.

The regression suite therefore tests a branch-list shape that normal creation does not produce. The final mockBranches.has(...) assertion also proves non-invocation only for deleteBranch. The remaining mutation methods could run before the check and still satisfy rejects.toThrow(scopeError).

Can we build a production-shaped list and spy on every destructive method, asserting zero calls after rejection?

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