-
Notifications
You must be signed in to change notification settings - Fork 391
fix: enforce project_ref scope on destructive branch tools #351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -151,6 +151,36 @@ export const branchingToolDefs = { | |
| }, | ||
| } as const satisfies ToolDefs; | ||
|
|
||
| /** | ||
| * When the MCP server is scoped to a single project, destructive branch tools | ||
| * must not accept branch IDs that belong to another parent project. | ||
| * | ||
| * create_branch / list_branches already inject project_id; delete/merge/reset/ | ||
| * rebase only take branch_id and previously forwarded it unchecked. | ||
| * | ||
| * branch_id_or_ref may be either the branch UUID or the branch project_ref. | ||
| */ | ||
| async function assertBranchBelongsToScopedProject( | ||
| branching: BranchingOperations, | ||
| branchId: string, | ||
| projectId: string | undefined | ||
| ) { | ||
| if (!projectId) { | ||
| return; | ||
| } | ||
|
|
||
| const branches = await branching.listBranches(projectId); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the server is scoped to a branch's own 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 |
||
| const belongs = branches.some( | ||
| (branch) => branch.id === branchId || branch.project_ref === branchId | ||
| ); | ||
|
|
||
| if (!belongs) { | ||
| throw new Error( | ||
| `Branch '${branchId}' is not a development branch of the scoped project '${projectId}'.` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export function getBranchingTools({ | ||
| branching, | ||
| projectId, | ||
|
|
@@ -191,6 +221,11 @@ export function getBranchingTools({ | |
| throw new Error('Cannot delete a branch in read-only mode.'); | ||
| } | ||
|
|
||
| await assertBranchBelongsToScopedProject( | ||
| branching, | ||
| branch_id, | ||
| project_id | ||
| ); | ||
| await branching.deleteBranch(branch_id); | ||
| return { success: true }; | ||
| }, | ||
|
|
@@ -202,6 +237,11 @@ export function getBranchingTools({ | |
| throw new Error('Cannot merge a branch in read-only mode.'); | ||
| } | ||
|
|
||
| await assertBranchBelongsToScopedProject( | ||
| branching, | ||
| branch_id, | ||
| project_id | ||
| ); | ||
| await branching.mergeBranch(branch_id); | ||
| return { success: true }; | ||
| }, | ||
|
|
@@ -213,6 +253,11 @@ export function getBranchingTools({ | |
| throw new Error('Cannot reset a branch in read-only mode.'); | ||
| } | ||
|
|
||
| await assertBranchBelongsToScopedProject( | ||
| branching, | ||
| branch_id, | ||
| project_id | ||
| ); | ||
| await branching.resetBranch(branch_id, { | ||
| migration_version, | ||
| }); | ||
|
|
@@ -226,6 +271,11 @@ export function getBranchingTools({ | |
| throw new Error('Cannot rebase a branch in read-only mode.'); | ||
| } | ||
|
|
||
| await assertBranchBelongsToScopedProject( | ||
| branching, | ||
| branch_id, | ||
| project_id | ||
| ); | ||
| await branching.rebaseBranch(branch_id); | ||
| return { success: true }; | ||
| }, | ||
|
|
||
There was a problem hiding this comment.
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
createBranchhelper, which creates only a non-default row. The normal mockedcreate_branchflow also creates a default row withproject_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 fordeleteBranch. The remaining mutation methods could run before the check and still satisfyrejects.toThrow(scopeError).Can we build a production-shaped list and spy on every destructive method, asserting zero calls after rejection?